feat: complete Phase 2 SaaS Full-Stack Todo Application
Browse filesPhase 2: SaaS Web Application with Authentication and Real Database
β¨ Features Implemented:
- Complete FastAPI backend with PostgreSQL (Neon)
- Next.js 14 frontend with TypeScript and Tailwind
- JWT authentication with bcrypt password hashing
- Full CRUD operations for todos
- User isolation and security
- Modern responsive UI with dark mode
- API documentation with OpenAPI/Swagger
π§ Tech Stack:
Backend:
- FastAPI with SQLModel
- PostgreSQL (Neon cloud database)
- JWT authentication
- Alembic migrations
- CORS middleware
Frontend:
- Next.js 14 with App Router
- TypeScript
- Tailwind CSS
- Radix UI components
- Framer Motion animations
- Custom hooks for auth and todos
π¦ What's Included:
- Backend API with auth, todos, users, AI endpoints
- Frontend with login, register, dashboard, profile pages
- Docker Compose for local development
- Comprehensive documentation
- All test flows validated (signup, login, CRUD, logout)
π Security:
- Password hashing with bcrypt
- JWT token-based authentication
- User-specific data isolation
- Environment variable configuration
π§ͺ Validated Flows:
β
User registration and login
β
Session management
β
Todo CRUD operations
β
Data persistence
β
User isolation
This completes Phase 2 of the Evolution of Todo project.
Next phase will merge to main branch for production deployment.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- .claude/settings.local.json +9 -0
- .gitignore +236 -7
- .specify/memory/constitution.md +37 -350
- CLAUDE.md +1 -1
- backend/.env.example +44 -0
- backend/README.md +165 -0
- backend/alembic.ini +113 -0
- backend/alembic/env.py +81 -0
- backend/alembic/script.py.mako +26 -0
- backend/alembic/versions/001_initial_schema.py +185 -0
- backend/jest.config.js +28 -0
- backend/pyproject.toml +156 -0
- backend/src/__init__.py +0 -0
- backend/src/api/__init__.py +0 -0
- backend/src/api/ai.py +159 -0
- backend/src/api/auth.py +276 -0
- backend/src/api/deps.py +171 -0
- backend/src/api/todos.py +347 -0
- backend/src/api/users.py +173 -0
- backend/src/core/__init__.py +0 -0
- backend/src/core/config.py +132 -0
- backend/src/core/database.py +103 -0
- backend/src/core/security.py +154 -0
- backend/src/main.py +126 -0
- backend/src/models/__init__.py +0 -0
- backend/src/models/ai_request.py +93 -0
- backend/src/models/session.py +91 -0
- backend/src/models/todo.py +124 -0
- backend/src/models/user.py +65 -0
- backend/src/schemas/__init__.py +0 -0
- backend/src/schemas/auth.py +61 -0
- backend/src/schemas/todo.py +60 -0
- backend/src/schemas/user.py +66 -0
- backend/src/services/__init__.py +0 -0
- backend/src/services/ai_service.py +239 -0
- backend/src/services/auth_service.py +205 -0
- backend/src/tests/__init__.py +0 -0
- backend/src/utils/__init__.py +0 -0
- cookies.txt +5 -0
- docker-compose.override.yml.example +18 -0
- docker-compose.yml +46 -0
- frontend/.env.example +20 -0
- frontend/.eslintrc.json +13 -0
- frontend/.prettierrc +8 -0
- frontend/README.md +191 -0
- frontend/components.json +20 -0
- frontend/jest.setup.js +2 -0
- frontend/next-env.d.ts +5 -0
- frontend/next.config.js +67 -0
- frontend/package.json +63 -0
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(set NODE_OPTIONS=--preserve-symlinks)",
|
| 5 |
+
"Bash(npm run dev:*)",
|
| 6 |
+
"Bash(curl:*)"
|
| 7 |
+
]
|
| 8 |
+
}
|
| 9 |
+
}
|
|
@@ -1,4 +1,45 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
__pycache__/
|
| 3 |
*.py[cod]
|
| 4 |
*$py.class
|
|
@@ -16,26 +57,214 @@ parts/
|
|
| 16 |
sdist/
|
| 17 |
var/
|
| 18 |
wheels/
|
|
|
|
| 19 |
*.egg-info/
|
| 20 |
.installed.cfg
|
| 21 |
*.egg
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
venv/
|
| 25 |
ENV/
|
| 26 |
env/
|
| 27 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
#
|
|
|
|
|
|
|
| 30 |
.vscode/
|
| 31 |
.idea/
|
| 32 |
*.swp
|
| 33 |
*.swo
|
| 34 |
*~
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
#
|
|
|
|
|
|
|
| 37 |
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
Thumbs.db
|
|
|
|
| 39 |
|
| 40 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 2 Premium Web SaaS - Comprehensive .gitignore
|
| 2 |
+
# Covers: Next.js 14, FastAPI, Python, Node.js, TypeScript, Tailwind, shadcn/ui
|
| 3 |
+
|
| 4 |
+
# ========================================
|
| 5 |
+
# Environment Variables & Secrets
|
| 6 |
+
# ========================================
|
| 7 |
+
.env
|
| 8 |
+
.env.local
|
| 9 |
+
.env.*.local
|
| 10 |
+
.env.development.local
|
| 11 |
+
.env.test.local
|
| 12 |
+
.env.production.local
|
| 13 |
+
.env.production
|
| 14 |
+
*.pem
|
| 15 |
+
*.key
|
| 16 |
+
*.crt
|
| 17 |
+
secrets/
|
| 18 |
+
credentials/
|
| 19 |
+
|
| 20 |
+
# ========================================
|
| 21 |
+
# Node.js / NPM / TypeScript
|
| 22 |
+
# ========================================
|
| 23 |
+
node_modules/
|
| 24 |
+
npm-debug.log*
|
| 25 |
+
yarn-debug.log*
|
| 26 |
+
yarn-error.log*
|
| 27 |
+
pnpm-debug.log*
|
| 28 |
+
dist/
|
| 29 |
+
dist-ssr/
|
| 30 |
+
build/
|
| 31 |
+
.next/
|
| 32 |
+
out/
|
| 33 |
+
.turbo/
|
| 34 |
+
.vercel/
|
| 35 |
+
*.tsbuildinfo
|
| 36 |
+
*.log
|
| 37 |
+
logs/
|
| 38 |
+
*.lnk
|
| 39 |
+
|
| 40 |
+
# ========================================
|
| 41 |
+
# Python / FastAPI / SQLModel
|
| 42 |
+
# ========================================
|
| 43 |
__pycache__/
|
| 44 |
*.py[cod]
|
| 45 |
*$py.class
|
|
|
|
| 57 |
sdist/
|
| 58 |
var/
|
| 59 |
wheels/
|
| 60 |
+
share/python-wheels/
|
| 61 |
*.egg-info/
|
| 62 |
.installed.cfg
|
| 63 |
*.egg
|
| 64 |
+
MANIFEST
|
| 65 |
+
pip-log.txt
|
| 66 |
+
pip-delete-this-directory.txt
|
| 67 |
+
.pytest_cache/
|
| 68 |
+
.coverage
|
| 69 |
+
htmlcov/
|
| 70 |
+
.tox/
|
| 71 |
+
.nox/
|
| 72 |
+
.coverage.*
|
| 73 |
+
.cache
|
| 74 |
+
nosetests.xml
|
| 75 |
+
coverage.xml
|
| 76 |
+
*.cover
|
| 77 |
+
*.log
|
| 78 |
+
.pytest_cache/
|
| 79 |
+
.hypothesis/
|
| 80 |
+
.venv/
|
| 81 |
venv/
|
| 82 |
ENV/
|
| 83 |
env/
|
| 84 |
+
venv.bak/
|
| 85 |
+
venv.stamp/
|
| 86 |
+
|
| 87 |
+
# ========================================
|
| 88 |
+
# Alembic (Database Migrations)
|
| 89 |
+
# ========================================
|
| 90 |
+
alembic/versions/*.pyc
|
| 91 |
+
alembic/versions/__pycache__/
|
| 92 |
|
| 93 |
+
# ========================================
|
| 94 |
+
# IDE & Editor Files
|
| 95 |
+
# ========================================
|
| 96 |
.vscode/
|
| 97 |
.idea/
|
| 98 |
*.swp
|
| 99 |
*.swo
|
| 100 |
*~
|
| 101 |
+
.DS_Store
|
| 102 |
+
.DS_Store?
|
| 103 |
+
._*
|
| 104 |
+
.Spotlight-V100
|
| 105 |
+
.Trashes
|
| 106 |
+
ehthumbs.db
|
| 107 |
+
Thumbs.db
|
| 108 |
+
*.sublime-project
|
| 109 |
+
*.sublime-workspace
|
| 110 |
+
.history/
|
| 111 |
+
*.fdb_cdblock/
|
| 112 |
+
.project
|
| 113 |
+
.classpath
|
| 114 |
+
.settings/
|
| 115 |
+
*.launch
|
| 116 |
+
.factorypath
|
| 117 |
+
.metadata.gradle
|
| 118 |
+
.gradle/
|
| 119 |
+
*.iml
|
| 120 |
+
*.ipr
|
| 121 |
+
*.iws
|
| 122 |
+
.project.settings
|
| 123 |
+
.settings/
|
| 124 |
+
.loadpath
|
| 125 |
+
.recommenders
|
| 126 |
+
.springBeans
|
| 127 |
+
.sts4-cache
|
| 128 |
+
.idea_modules/
|
| 129 |
+
.vs/
|
| 130 |
+
*.code-workspace
|
| 131 |
+
|
| 132 |
+
# ========================================
|
| 133 |
+
# Testing & Coverage
|
| 134 |
+
# ========================================
|
| 135 |
+
coverage/
|
| 136 |
+
*.lcov
|
| 137 |
+
.nyc_output/
|
| 138 |
+
playwright-report/
|
| 139 |
+
test-results/
|
| 140 |
+
*.test.tsx.snap
|
| 141 |
+
*.mock.tsx
|
| 142 |
|
| 143 |
+
# ========================================
|
| 144 |
+
# OS Generated Files
|
| 145 |
+
# ========================================
|
| 146 |
.DS_Store
|
| 147 |
+
.DS_Store?
|
| 148 |
+
._*
|
| 149 |
+
.Spotlight-V100
|
| 150 |
+
.Trashes
|
| 151 |
+
ehthumbs.db
|
| 152 |
Thumbs.db
|
| 153 |
+
desktop.ini
|
| 154 |
|
| 155 |
+
# ========================================
|
| 156 |
+
# Temporary Files
|
| 157 |
+
# ========================================
|
| 158 |
+
*.tmp
|
| 159 |
+
*.temp
|
| 160 |
+
*.cache
|
| 161 |
+
*.bak
|
| 162 |
+
*.backup
|
| 163 |
+
*.swp
|
| 164 |
+
*.swo
|
| 165 |
+
*~.nib
|
| 166 |
+
*.sql
|
| 167 |
+
*.sqlite
|
| 168 |
+
*.db
|
| 169 |
+
|
| 170 |
+
# ========================================
|
| 171 |
+
# Build & Distribution Artifacts
|
| 172 |
+
# ========================================
|
| 173 |
+
*.tgz
|
| 174 |
+
*.tar.gz
|
| 175 |
+
*.rar
|
| 176 |
+
*.zip
|
| 177 |
+
*.7z
|
| 178 |
+
*.exe
|
| 179 |
+
*.dll
|
| 180 |
+
*.dylib
|
| 181 |
+
*.bin
|
| 182 |
+
*.obj/
|
| 183 |
+
out/
|
| 184 |
+
target/
|
| 185 |
+
*.class
|
| 186 |
+
*.jar
|
| 187 |
+
*.war
|
| 188 |
+
*.ear
|
| 189 |
+
|
| 190 |
+
# ========================================
|
| 191 |
+
# Cloud / Deployment
|
| 192 |
+
# ========================================
|
| 193 |
+
.vercel
|
| 194 |
+
.netlify
|
| 195 |
+
.firebase/
|
| 196 |
+
amplify/
|
| 197 |
+
#debug.log
|
| 198 |
+
*.log
|
| 199 |
+
|
| 200 |
+
# ========================================
|
| 201 |
+
# Package Manager Lock Files (Optional)
|
| 202 |
+
# ========================================
|
| 203 |
+
# Uncomment if you want to ignore lock files
|
| 204 |
+
# package-lock.json
|
| 205 |
+
# yarn.lock
|
| 206 |
+
# pnpm-lock.yaml
|
| 207 |
+
# poetry.lock
|
| 208 |
+
# Pipfile.lock
|
| 209 |
+
|
| 210 |
+
# ========================================
|
| 211 |
+
# Docker
|
| 212 |
+
# ========================================
|
| 213 |
+
*.dockerfile
|
| 214 |
+
docker-compose.override.yml
|
| 215 |
+
|
| 216 |
+
# ========================================
|
| 217 |
+
# Database Files
|
| 218 |
+
# ========================================
|
| 219 |
+
*.db
|
| 220 |
+
*.sqlite
|
| 221 |
+
*.sqlite3
|
| 222 |
+
*.db3
|
| 223 |
+
*.psql
|
| 224 |
+
*.sql
|
| 225 |
+
*.sqlitedb
|
| 226 |
+
|
| 227 |
+
# ========================================
|
| 228 |
+
# Cloudinary / Image Uploads (if local storage)
|
| 229 |
+
# ========================================
|
| 230 |
+
uploads/
|
| 231 |
+
public/uploads/
|
| 232 |
+
static/uploads/
|
| 233 |
+
temp/
|
| 234 |
+
|
| 235 |
+
# ========================================
|
| 236 |
+
# AI / ML Model Files
|
| 237 |
+
# ========================================
|
| 238 |
+
*.pkl
|
| 239 |
+
*.h5
|
| 240 |
+
*.hdf5
|
| 241 |
+
*.pb
|
| 242 |
+
*.onnx
|
| 243 |
+
*.ckpt
|
| 244 |
+
*.pt
|
| 245 |
+
*.pth
|
| 246 |
+
|
| 247 |
+
# ========================================
|
| 248 |
+
# Logs & Debugging
|
| 249 |
+
# ========================================
|
| 250 |
*.log
|
| 251 |
+
logs/
|
| 252 |
+
*.debug
|
| 253 |
+
debug.log
|
| 254 |
+
error.log
|
| 255 |
+
access.log
|
| 256 |
+
server.log
|
| 257 |
+
application.log
|
| 258 |
+
|
| 259 |
+
# ========================================
|
| 260 |
+
# Misc
|
| 261 |
+
# ========================================
|
| 262 |
+
.site/
|
| 263 |
+
.sass-cache/
|
| 264 |
+
.jekyll-cache/
|
| 265 |
+
.jekyll-metadata
|
| 266 |
+
.jekyll-server-cache
|
| 267 |
+
package-lock.json
|
| 268 |
+
yarn.lock
|
| 269 |
+
pnpm-lock.yaml
|
| 270 |
+
nul
|
|
@@ -1,368 +1,55 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
Version Change: Initial β 1.0.0
|
| 4 |
-
Modified Principles: N/A (initial creation)
|
| 5 |
-
Added Sections:
|
| 6 |
-
- Core Principles (12 sections)
|
| 7 |
-
- Phase Evolution Contract
|
| 8 |
-
- Phase-Wise Enforcement Rules
|
| 9 |
-
- Architecture Principles
|
| 10 |
-
- Security Rules
|
| 11 |
-
- Technology Constraints
|
| 12 |
-
- Error Handling Rules
|
| 13 |
-
- Change Management
|
| 14 |
-
- Enforcement Hierarchy
|
| 15 |
-
- Definition of Success
|
| 16 |
-
Removed Sections: N/A
|
| 17 |
-
Templates Requiring Updates:
|
| 18 |
-
- .specify/templates/plan-template.md (Constitution Check section needs phase-specific gates)
|
| 19 |
-
- .specify/templates/spec-template.md (aligned with constitution requirements)
|
| 20 |
-
- .specify/templates/tasks-template.md (aligned with phase-based enforcement)
|
| 21 |
-
Follow-up TODOs: None
|
| 22 |
-
-->
|
| 23 |
-
|
| 24 |
-
# Evolution of Todo Constitution
|
| 25 |
|
| 26 |
## Core Principles
|
| 27 |
|
| 28 |
-
###
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
The primary objective is **architectural discipline**, not feature velocity.
|
| 33 |
-
|
| 34 |
-
---
|
| 35 |
-
|
| 36 |
-
### II. Spec-Driven Development Only
|
| 37 |
-
|
| 38 |
-
All work MUST follow this strict order:
|
| 39 |
-
|
| 40 |
-
Constitution β Specify β Plan β Tasks β Implement
|
| 41 |
-
|
| 42 |
-
**Mandatory Rules:**
|
| 43 |
-
- No skipping steps
|
| 44 |
-
- No merging steps
|
| 45 |
-
- No code without tasks
|
| 46 |
-
|
| 47 |
-
**Rationale:** This ensures every implementation decision is traceable to requirements, prevents scope creep, and maintains architectural integrity across all evolution phases.
|
| 48 |
-
|
| 49 |
-
---
|
| 50 |
-
|
| 51 |
-
### III. No Manual Coding
|
| 52 |
-
|
| 53 |
-
**Non-Negotiable Rules:**
|
| 54 |
-
- Humans MUST NOT write application code
|
| 55 |
-
- ALL code must be generated via `/sp.implement`
|
| 56 |
-
- Humans MAY: edit specs, review output, request regeneration
|
| 57 |
-
|
| 58 |
-
**Rationale:** Manual coding bypasses the spec-driven workflow and introduces untraceable behavior changes. Manual coding equals phase failure.
|
| 59 |
-
|
| 60 |
-
---
|
| 61 |
-
|
| 62 |
-
### IV. Single Source of Truth
|
| 63 |
-
|
| 64 |
-
**Mandatory Rules:**
|
| 65 |
-
- Specs are the only authority
|
| 66 |
-
- If behavior is not written, it does not exist
|
| 67 |
-
- Implementation may NEVER introduce new behavior
|
| 68 |
-
|
| 69 |
-
**Rationale:** Prevents implementation drift and ensures all features are properly specified, reviewed, and approved before coding begins.
|
| 70 |
-
|
| 71 |
-
---
|
| 72 |
-
|
| 73 |
-
### V. Phase Evolution Contract
|
| 74 |
-
|
| 75 |
-
The project MUST evolve strictly in this order:
|
| 76 |
-
|
| 77 |
-
| Phase | Scope |
|
| 78 |
-
|-----|-----|
|
| 79 |
-
| Phase I | In-memory console app |
|
| 80 |
-
| Phase II | Full-stack web app |
|
| 81 |
-
| Phase III | AI agents via MCP |
|
| 82 |
-
| Phase IV | Kubernetes deployment |
|
| 83 |
-
| Phase V | Event-driven cloud system |
|
| 84 |
-
|
| 85 |
-
**Non-Negotiable:** No phase may skip responsibilities.
|
| 86 |
-
|
| 87 |
-
**Rationale:** Each phase builds upon previous foundations. Skipping phases breaks the evolutionary principle and introduces architectural debt.
|
| 88 |
-
|
| 89 |
-
---
|
| 90 |
-
|
| 91 |
-
### VI. Stateless Services
|
| 92 |
-
|
| 93 |
-
**Mandatory Rules:**
|
| 94 |
-
- Backend services MUST be stateless
|
| 95 |
-
- State stored in: Database or Dapr state store
|
| 96 |
-
- Restarting services must not break functionality
|
| 97 |
-
|
| 98 |
-
**Rationale:** Enables horizontal scaling, fault tolerance, and cloud-native deployment patterns. Stateful services create scaling bottlenecks and operational complexity.
|
| 99 |
-
|
| 100 |
-
---
|
| 101 |
-
|
| 102 |
-
### VII. Agent-First Design
|
| 103 |
-
|
| 104 |
-
**Mandatory Rules:**
|
| 105 |
-
- Agents invoke tools, not functions
|
| 106 |
-
- All agent behavior must be explicit
|
| 107 |
-
- No autonomous free-form execution
|
| 108 |
-
|
| 109 |
-
**Rationale:** Explicit tool invocations are auditable, testable, andε―ζ§. Free-form execution creates unpredictable behavior and security risks.
|
| 110 |
-
|
| 111 |
-
---
|
| 112 |
-
|
| 113 |
-
### VIII. Event-Driven by Default (Phase V)
|
| 114 |
-
|
| 115 |
-
**Mandatory Rules:**
|
| 116 |
-
- Events represent facts
|
| 117 |
-
- Consumers react independently
|
| 118 |
-
- No synchronous dependencies
|
| 119 |
-
|
| 120 |
-
**Rationale:** Enables loose coupling, independent scaling, and resilience. Synchronous dependencies create cascading failures and tight coupling.
|
| 121 |
-
|
| 122 |
-
---
|
| 123 |
-
|
| 124 |
-
### IX. Security Rules
|
| 125 |
-
|
| 126 |
-
**Mandatory Rules:**
|
| 127 |
-
- Authentication mandatory once introduced
|
| 128 |
-
- JWT verification at backend boundary
|
| 129 |
-
- User data isolation enforced in backend
|
| 130 |
-
- Secrets NEVER hard-coded
|
| 131 |
-
- No trust in frontend
|
| 132 |
-
|
| 133 |
-
**Rationale:** Defense-in-depth prevents unauthorized access and data leakage. Frontend is inherently untrustworthy; backend must enforce all security rules.
|
| 134 |
-
|
| 135 |
-
---
|
| 136 |
-
|
| 137 |
-
### X. Technology Constraints
|
| 138 |
-
|
| 139 |
-
**Allowed Stack:**
|
| 140 |
-
- Frontend: Next.js (App Router)
|
| 141 |
-
- Backend: FastAPI (Python)
|
| 142 |
-
- ORM: SQLModel
|
| 143 |
-
- Database: PostgreSQL (Neon)
|
| 144 |
-
- Auth: Better Auth
|
| 145 |
-
- AI: OpenAI Agents SDK
|
| 146 |
-
- MCP: Official MCP SDK
|
| 147 |
-
- Orchestration: Kubernetes
|
| 148 |
-
- Messaging: Kafka (via Dapr)
|
| 149 |
-
|
| 150 |
-
**Non-Negotiable:** Changes require spec updates.
|
| 151 |
-
|
| 152 |
-
**Rationale:** Standardized stack reduces complexity, improves maintainability, and ensures team expertise depth.
|
| 153 |
-
|
| 154 |
-
---
|
| 155 |
-
|
| 156 |
-
### XI. Error Handling
|
| 157 |
-
|
| 158 |
-
**Mandatory Rules:**
|
| 159 |
-
- Errors must be user-friendly
|
| 160 |
-
- No crashes on invalid input
|
| 161 |
-
- System must recover gracefully
|
| 162 |
-
- Errors must not leak internals
|
| 163 |
-
|
| 164 |
-
**Rationale:** User experience and security. Crashes and leaked internals create frustration and security vulnerabilities.
|
| 165 |
-
|
| 166 |
-
---
|
| 167 |
-
|
| 168 |
-
### XII. Change Management
|
| 169 |
-
|
| 170 |
-
**Change Type Mapping:**
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
| Tasks | Update `speckit.tasks` |
|
| 177 |
-
| Principles | Update this constitution |
|
| 178 |
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
###
|
| 186 |
|
| 187 |
-
**Scope Constraints:**
|
| 188 |
-
- Single user
|
| 189 |
-
- In-memory only
|
| 190 |
-
- No database
|
| 191 |
-
- No web
|
| 192 |
-
- No auth
|
| 193 |
-
- No AI
|
| 194 |
-
- No agents
|
| 195 |
|
| 196 |
-
|
| 197 |
|
| 198 |
-
|
|
|
|
| 199 |
|
| 200 |
-
|
|
|
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
- REST APIs
|
| 205 |
-
- Frontend + backend separation
|
| 206 |
-
- Authentication mandatory
|
| 207 |
-
- User-level data isolation
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
---
|
| 212 |
-
|
| 213 |
-
### Phase III β AI & MCP
|
| 214 |
-
|
| 215 |
-
**Scope Requirements:**
|
| 216 |
-
- AI agents MUST operate via MCP tools
|
| 217 |
-
- No direct DB access by agents
|
| 218 |
-
- Chat must be stateless
|
| 219 |
-
- Conversation state persisted externally
|
| 220 |
-
|
| 221 |
-
**Rationale:** Enable AI capabilities while maintaining security and scalability.
|
| 222 |
-
|
| 223 |
-
---
|
| 224 |
-
|
| 225 |
-
### Phase IV β Kubernetes
|
| 226 |
-
|
| 227 |
-
**Scope Requirements:**
|
| 228 |
-
- All services containerized
|
| 229 |
-
- Helm charts required
|
| 230 |
-
- Minikube parity with production
|
| 231 |
-
- No environment-specific logic
|
| 232 |
-
|
| 233 |
-
**Rationale:** Enable cloud-native deployment and operational consistency.
|
| 234 |
-
|
| 235 |
-
---
|
| 236 |
-
|
| 237 |
-
### Phase V β Event-Driven Cloud
|
| 238 |
-
|
| 239 |
-
**Scope Requirements:**
|
| 240 |
-
- CRUD emits events
|
| 241 |
-
- Asynchronous consumers
|
| 242 |
-
- Kafka via Dapr only
|
| 243 |
-
- No service-to-service tight coupling
|
| 244 |
-
|
| 245 |
-
**Rationale:** Enable distributed system patterns and independent scaling.
|
| 246 |
-
|
| 247 |
-
---
|
| 248 |
-
|
| 249 |
-
## Architecture Principles
|
| 250 |
-
|
| 251 |
-
### 1. Stateless Services
|
| 252 |
-
|
| 253 |
-
Backend services MUST be stateless. State stored in:
|
| 254 |
-
- Database (PostgreSQL/Neon)
|
| 255 |
-
- Dapr state store (Phase III+)
|
| 256 |
-
|
| 257 |
-
Restarting services must not break functionality.
|
| 258 |
-
|
| 259 |
-
---
|
| 260 |
-
|
| 261 |
-
### 2. Agent-First Design
|
| 262 |
-
|
| 263 |
-
- Agents invoke tools, not functions
|
| 264 |
-
- All agent behavior must be explicit
|
| 265 |
-
- No autonomous free-form execution
|
| 266 |
-
|
| 267 |
-
---
|
| 268 |
-
|
| 269 |
-
### 3. Event-Driven by Default (Phase V)
|
| 270 |
-
|
| 271 |
-
- Events represent facts
|
| 272 |
-
- Consumers react independently
|
| 273 |
-
- No synchronous dependencies
|
| 274 |
-
|
| 275 |
-
---
|
| 276 |
-
|
| 277 |
-
## Security Rules
|
| 278 |
-
|
| 279 |
-
- Authentication mandatory once introduced
|
| 280 |
-
- JWT verification at backend boundary
|
| 281 |
-
- User data isolation enforced in backend
|
| 282 |
-
- Secrets NEVER hard-coded
|
| 283 |
-
- No trust in frontend
|
| 284 |
-
|
| 285 |
-
---
|
| 286 |
-
|
| 287 |
-
## Technology Stack
|
| 288 |
-
|
| 289 |
-
**Allowed Technologies:**
|
| 290 |
-
- Frontend: Next.js (App Router)
|
| 291 |
-
- Backend: FastAPI (Python)
|
| 292 |
-
- ORM: SQLModel
|
| 293 |
-
- Database: PostgreSQL (Neon)
|
| 294 |
-
- Auth: Better Auth
|
| 295 |
-
- AI: OpenAI Agents SDK
|
| 296 |
-
- MCP: Official MCP SDK
|
| 297 |
-
- Orchestration: Kubernetes
|
| 298 |
-
- Messaging: Kafka (via Dapr)
|
| 299 |
-
|
| 300 |
-
**Changes require spec updates.**
|
| 301 |
-
|
| 302 |
-
---
|
| 303 |
-
|
| 304 |
-
## Error Handling Standards
|
| 305 |
-
|
| 306 |
-
- Errors must be user-friendly
|
| 307 |
-
- No crashes on invalid input
|
| 308 |
-
- System must recover gracefully
|
| 309 |
-
- Errors must not leak internals
|
| 310 |
-
|
| 311 |
-
---
|
| 312 |
-
|
| 313 |
-
## Enforcement Hierarchy
|
| 314 |
-
|
| 315 |
-
If conflicts occur, precedence is:
|
| 316 |
-
|
| 317 |
-
Constitution > Specify > Plan > Tasks > Implementation
|
| 318 |
-
|
| 319 |
-
Lower layers MUST obey higher layers.
|
| 320 |
-
|
| 321 |
-
---
|
| 322 |
-
|
| 323 |
-
## Definition of Success
|
| 324 |
-
|
| 325 |
-
This project is successful when:
|
| 326 |
-
|
| 327 |
-
- Every feature traces to a spec
|
| 328 |
-
- No manual code exists
|
| 329 |
-
- Agents operate only via tools
|
| 330 |
-
- System scales from CLI to cloud
|
| 331 |
-
- Architecture is explainable and auditable
|
| 332 |
-
|
| 333 |
-
---
|
| 334 |
-
|
| 335 |
-
## Final Rule
|
| 336 |
-
|
| 337 |
-
If it is not specified,
|
| 338 |
-
**it is forbidden.**
|
| 339 |
-
|
| 340 |
-
---
|
| 341 |
|
| 342 |
## Governance
|
|
|
|
| 343 |
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
1. Changes to this constitution require:
|
| 347 |
-
- Documentation of rationale
|
| 348 |
-
- Impact analysis on existing specs
|
| 349 |
-
- Migration plan for affected features
|
| 350 |
-
- Version bump following semantic versioning
|
| 351 |
-
|
| 352 |
-
2. Versioning Policy:
|
| 353 |
-
- **MAJOR**: Backward incompatible governance/principle removals or redefinitions
|
| 354 |
-
- **MINOR**: New principle/section added or materially expanded guidance
|
| 355 |
-
- **PATCH**: Clarifications, wording, typo fixes, non-semantic refinements
|
| 356 |
-
|
| 357 |
-
3. Compliance Review:
|
| 358 |
-
- All PRs MUST verify constitution compliance
|
| 359 |
-
- Plan templates MUST include constitution check gates
|
| 360 |
-
- Spec templates MUST enforce principle requirements
|
| 361 |
-
|
| 362 |
-
### Enforcement
|
| 363 |
-
|
| 364 |
-
- Constitution supersedes all other practices
|
| 365 |
-
- Complexity MUST be justified against principles
|
| 366 |
-
- All agents and tools MUST follow constitution rules
|
| 367 |
|
| 368 |
-
**Version**:
|
|
|
|
|
|
| 1 |
+
# [PROJECT_NAME] Constitution
|
| 2 |
+
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
## Core Principles
|
| 5 |
|
| 6 |
+
### [PRINCIPLE_1_NAME]
|
| 7 |
+
<!-- Example: I. Library-First -->
|
| 8 |
+
[PRINCIPLE_1_DESCRIPTION]
|
| 9 |
+
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
### [PRINCIPLE_2_NAME]
|
| 12 |
+
<!-- Example: II. CLI Interface -->
|
| 13 |
+
[PRINCIPLE_2_DESCRIPTION]
|
| 14 |
+
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args β stdout, errors β stderr; Support JSON + human-readable formats -->
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
### [PRINCIPLE_3_NAME]
|
| 17 |
+
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
| 18 |
+
[PRINCIPLE_3_DESCRIPTION]
|
| 19 |
+
<!-- Example: TDD mandatory: Tests written β User approved β Tests fail β Then implement; Red-Green-Refactor cycle strictly enforced -->
|
| 20 |
|
| 21 |
+
### [PRINCIPLE_4_NAME]
|
| 22 |
+
<!-- Example: IV. Integration Testing -->
|
| 23 |
+
[PRINCIPLE_4_DESCRIPTION]
|
| 24 |
+
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
| 25 |
|
| 26 |
+
### [PRINCIPLE_5_NAME]
|
| 27 |
+
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
| 28 |
+
[PRINCIPLE_5_DESCRIPTION]
|
| 29 |
+
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
| 30 |
|
| 31 |
+
### [PRINCIPLE_6_NAME]
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
[PRINCIPLE__DESCRIPTION]
|
| 35 |
|
| 36 |
+
## [SECTION_2_NAME]
|
| 37 |
+
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
| 38 |
|
| 39 |
+
[SECTION_2_CONTENT]
|
| 40 |
+
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
| 41 |
|
| 42 |
+
## [SECTION_3_NAME]
|
| 43 |
+
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
[SECTION_3_CONTENT]
|
| 46 |
+
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
## Governance
|
| 49 |
+
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
| 50 |
|
| 51 |
+
[GOVERNANCE_RULES]
|
| 52 |
+
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
| 55 |
+
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
This file is generated during init for the selected agent.
|
| 4 |
|
|
|
|
| 1 |
+
# Claude Code Rules
|
| 2 |
|
| 3 |
This file is generated during init for the selected agent.
|
| 4 |
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ========================================
|
| 2 |
+
# Database Configuration
|
| 3 |
+
# ========================================
|
| 4 |
+
# PostgreSQL connection string (use Neon for production)
|
| 5 |
+
DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/todoapp
|
| 6 |
+
|
| 7 |
+
# ========================================
|
| 8 |
+
# JWT Authentication
|
| 9 |
+
# ========================================
|
| 10 |
+
# Secret key for JWT token signing (must be at least 32 characters)
|
| 11 |
+
JWT_SECRET=your-super-secret-key-change-this-in-production-min-32-chars
|
| 12 |
+
|
| 13 |
+
# ========================================
|
| 14 |
+
# Cloudinary Configuration (Avatar Storage)
|
| 15 |
+
# ========================================
|
| 16 |
+
CLOUDINARY_CLOUD_NAME=your-cloud-name
|
| 17 |
+
CLOUDINARY_API_KEY=your-api-key
|
| 18 |
+
CLOUDINARY_API_SECRET=your-api-secret
|
| 19 |
+
|
| 20 |
+
# ========================================
|
| 21 |
+
# Hugging Face AI Configuration
|
| 22 |
+
# ========================================
|
| 23 |
+
HUGGINGFACE_API_KEY=your-huggingface-api-key
|
| 24 |
+
|
| 25 |
+
# ========================================
|
| 26 |
+
# Frontend URL
|
| 27 |
+
# ========================================
|
| 28 |
+
# Allowed CORS origin for frontend
|
| 29 |
+
FRONTEND_URL=http://localhost:3000
|
| 30 |
+
|
| 31 |
+
# ========================================
|
| 32 |
+
# Application Settings
|
| 33 |
+
# ========================================
|
| 34 |
+
# Environment: development, staging, production
|
| 35 |
+
ENV=development
|
| 36 |
+
|
| 37 |
+
# API Port
|
| 38 |
+
PORT=8000
|
| 39 |
+
|
| 40 |
+
# ========================================
|
| 41 |
+
# Optional: Log Level
|
| 42 |
+
# ========================================
|
| 43 |
+
# debug, info, warning, error, critical
|
| 44 |
+
LOG_LEVEL=info
|
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Todo App Backend - Phase 2
|
| 2 |
+
|
| 3 |
+
FastAPI backend for the Todo SaaS application with authentication, database, and AI integration.
|
| 4 |
+
|
| 5 |
+
## Tech Stack
|
| 6 |
+
|
| 7 |
+
- **FastAPI** - Modern, fast web framework for building APIs
|
| 8 |
+
- **SQLModel** - SQLModel for ORM with Pydantic validation
|
| 9 |
+
- **Alembic** - Database migration tool
|
| 10 |
+
- **PostgreSQL** - Primary database (Neon in production)
|
| 11 |
+
- **JWT + bcrypt** - Secure authentication
|
| 12 |
+
- **Hugging Face** - AI integration for todo features
|
| 13 |
+
- **Cloudinary** - Avatar image storage
|
| 14 |
+
|
| 15 |
+
## Setup
|
| 16 |
+
|
| 17 |
+
### 1. Create virtual environment
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python -m venv venv
|
| 21 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### 2. Install dependencies
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
pip install -e .
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Or install with dev dependencies:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
pip install -e ".[dev]"
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### 3. Setup environment
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
cp .env.example .env
|
| 40 |
+
# Edit .env with your configuration
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### 4. Run database migrations
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
alembic upgrade head
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### 5. Start development server
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
uvicorn src.main:app --reload --port 8000
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
API will be available at: http://localhost:8000
|
| 56 |
+
API docs at: http://localhost:8000/docs
|
| 57 |
+
|
| 58 |
+
## Project Structure
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
backend/
|
| 62 |
+
βββ src/
|
| 63 |
+
β βββ api/ # API route handlers
|
| 64 |
+
β βββ core/ # Core configuration and utilities
|
| 65 |
+
β βββ models/ # SQLModel database models
|
| 66 |
+
β βββ schemas/ # Pydantic schemas for request/response
|
| 67 |
+
β βββ services/ # Business logic services
|
| 68 |
+
β βββ tests/ # Test files
|
| 69 |
+
β βββ utils/ # Utility functions
|
| 70 |
+
βββ alembic/ # Database migrations
|
| 71 |
+
βββ pyproject.toml # Project configuration
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
## Available Scripts
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# Development
|
| 78 |
+
python -m uvicorn src.main:app --reload
|
| 79 |
+
|
| 80 |
+
# Testing
|
| 81 |
+
pytest # Run tests
|
| 82 |
+
pytest --cov=src # Run with coverage
|
| 83 |
+
|
| 84 |
+
# Database migrations
|
| 85 |
+
alembic revision --autogenerate -m "message" # Create migration
|
| 86 |
+
alembic upgrade head # Apply migrations
|
| 87 |
+
alembic downgrade -1 # Rollback one migration
|
| 88 |
+
|
| 89 |
+
# Code quality
|
| 90 |
+
black . # Format code
|
| 91 |
+
ruff check . # Lint code
|
| 92 |
+
ruff check . --fix # Fix linting issues
|
| 93 |
+
mypy . # Type checking
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## API Endpoints
|
| 97 |
+
|
| 98 |
+
### Authentication
|
| 99 |
+
- `POST /auth/signup` - User registration
|
| 100 |
+
- `POST /auth/login` - User login
|
| 101 |
+
- `POST /auth/logout` - User logout
|
| 102 |
+
- `GET /auth/me` - Get current user
|
| 103 |
+
|
| 104 |
+
### Todos
|
| 105 |
+
- `GET /todos` - List todos with filtering
|
| 106 |
+
- `POST /todos` - Create todo
|
| 107 |
+
- `GET /todos/{id}` - Get single todo
|
| 108 |
+
- `PUT /todos/{id}` - Update todo
|
| 109 |
+
- `DELETE /todos/{id}` - Delete todo
|
| 110 |
+
- `PATCH /todos/{id}/complete` - Mark todo complete
|
| 111 |
+
|
| 112 |
+
### User Profile
|
| 113 |
+
- `GET /users/me` - Get user profile
|
| 114 |
+
- `PUT /users/me` - Update user profile
|
| 115 |
+
- `POST /users/me/avatar` - Upload avatar
|
| 116 |
+
|
| 117 |
+
### AI Features
|
| 118 |
+
- `POST /ai/generate-todo` - Generate todo from text
|
| 119 |
+
- `POST /ai/summarize` - Summarize todos
|
| 120 |
+
- `POST /ai/prioritize` - Prioritize todos
|
| 121 |
+
|
| 122 |
+
## Environment Variables
|
| 123 |
+
|
| 124 |
+
See `.env.example` for required environment variables:
|
| 125 |
+
|
| 126 |
+
- `DATABASE_URL` - PostgreSQL connection string
|
| 127 |
+
- `JWT_SECRET` - Secret key for JWT (min 32 chars)
|
| 128 |
+
- `CLOUDINARY_CLOUD_NAME` - Cloudinary cloud name
|
| 129 |
+
- `CLOUDINARY_API_KEY` - Cloudinary API key
|
| 130 |
+
- `CLOUDINARY_API_SECRET` - Cloudinary API secret
|
| 131 |
+
- `HUGGINGFACE_API_KEY` - Hugging Face API key
|
| 132 |
+
- `FRONTEND_URL` - Frontend URL for CORS
|
| 133 |
+
|
| 134 |
+
## Development with Docker
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
# Start PostgreSQL
|
| 138 |
+
docker-compose up -d postgres
|
| 139 |
+
|
| 140 |
+
# Run migrations
|
| 141 |
+
alembic upgrade head
|
| 142 |
+
|
| 143 |
+
# Start server
|
| 144 |
+
uvicorn src.main:app --reload
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
## Testing
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
# Run all tests
|
| 151 |
+
pytest
|
| 152 |
+
|
| 153 |
+
# Run with coverage
|
| 154 |
+
pytest --cov=src --cov-report=html
|
| 155 |
+
|
| 156 |
+
# Run specific test file
|
| 157 |
+
pytest tests/test_auth.py
|
| 158 |
+
|
| 159 |
+
# Run with verbose output
|
| 160 |
+
pytest -v
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
## License
|
| 164 |
+
|
| 165 |
+
MIT
|
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
script_location = alembic
|
| 6 |
+
|
| 7 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 8 |
+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
| 9 |
+
|
| 10 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 11 |
+
# defaults to the current working directory.
|
| 12 |
+
prepend_sys_path = .
|
| 13 |
+
|
| 14 |
+
# timezone to use when rendering the date within the migration file
|
| 15 |
+
# as well as the filename.
|
| 16 |
+
# If specified, requires the python-dateutil library that can be
|
| 17 |
+
# installed by adding `alembic[tz]` to the pip requirements
|
| 18 |
+
# string value is passed to dateutil.tz.gettz()
|
| 19 |
+
# leave blank for localtime
|
| 20 |
+
# timezone =
|
| 21 |
+
|
| 22 |
+
# max length of characters to apply to the
|
| 23 |
+
# "slug" field
|
| 24 |
+
# truncate_slug_length = 40
|
| 25 |
+
|
| 26 |
+
# set to 'true' to run the environment during
|
| 27 |
+
# the 'revision' command, regardless of autogenerate
|
| 28 |
+
# revision_environment = false
|
| 29 |
+
|
| 30 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 31 |
+
# a source .py file to be detected as revisions in the
|
| 32 |
+
# versions/ directory
|
| 33 |
+
# sourceless = false
|
| 34 |
+
|
| 35 |
+
# version location specification; This defaults
|
| 36 |
+
# to alembic/versions. When using multiple version
|
| 37 |
+
# directories, initial revisions must be specified with --version-path.
|
| 38 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
| 39 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
| 40 |
+
|
| 41 |
+
# version path separator; As mentioned above, this is the character used to split
|
| 42 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
| 43 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
| 44 |
+
# Valid values for version_path_separator are:
|
| 45 |
+
#
|
| 46 |
+
# version_path_separator = :
|
| 47 |
+
# version_path_separator = ;
|
| 48 |
+
# version_path_separator = space
|
| 49 |
+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
| 50 |
+
|
| 51 |
+
# set to 'true' to search source files recursively
|
| 52 |
+
# in each "version_locations" directory
|
| 53 |
+
# new in Alembic version 1.10
|
| 54 |
+
# recursive_version_locations = false
|
| 55 |
+
|
| 56 |
+
# the output encoding used when revision files
|
| 57 |
+
# are written from script.py.mako
|
| 58 |
+
# output_encoding = utf-8
|
| 59 |
+
|
| 60 |
+
sqlalchemy.url = postgresql+psycopg://user:password@localhost:5432/todoapp
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
[post_write_hooks]
|
| 64 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 65 |
+
# on newly generated revision scripts. See the documentation for further
|
| 66 |
+
# detail and examples
|
| 67 |
+
|
| 68 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 69 |
+
# hooks = black
|
| 70 |
+
# black.type = console_scripts
|
| 71 |
+
# black.entrypoint = black
|
| 72 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 73 |
+
|
| 74 |
+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
| 75 |
+
# hooks = ruff
|
| 76 |
+
# ruff.type = exec
|
| 77 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
| 78 |
+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
| 79 |
+
|
| 80 |
+
# Logging configuration
|
| 81 |
+
[loggers]
|
| 82 |
+
keys = root,sqlalchemy,alembic
|
| 83 |
+
|
| 84 |
+
[handlers]
|
| 85 |
+
keys = console
|
| 86 |
+
|
| 87 |
+
[formatters]
|
| 88 |
+
keys = generic
|
| 89 |
+
|
| 90 |
+
[logger_root]
|
| 91 |
+
level = WARN
|
| 92 |
+
handlers = console
|
| 93 |
+
qualname =
|
| 94 |
+
|
| 95 |
+
[logger_sqlalchemy]
|
| 96 |
+
level = WARN
|
| 97 |
+
handlers =
|
| 98 |
+
qualname = sqlalchemy.engine
|
| 99 |
+
|
| 100 |
+
[logger_alembic]
|
| 101 |
+
level = INFO
|
| 102 |
+
handlers =
|
| 103 |
+
qualname = alembic
|
| 104 |
+
|
| 105 |
+
[handler_console]
|
| 106 |
+
class = StreamHandler
|
| 107 |
+
args = (sys.stderr,)
|
| 108 |
+
level = NOTSET
|
| 109 |
+
formatter = generic
|
| 110 |
+
|
| 111 |
+
[formatter_generic]
|
| 112 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 113 |
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import engine_from_config, pool
|
| 4 |
+
|
| 5 |
+
from alembic import context
|
| 6 |
+
|
| 7 |
+
# Import your models here for autogenerate support
|
| 8 |
+
from src.core.config import settings
|
| 9 |
+
from src.models.user import User
|
| 10 |
+
from src.models.todo import Todo
|
| 11 |
+
from src.models.session import Session
|
| 12 |
+
from src.models.ai_request import AIRequest
|
| 13 |
+
from sqlmodel import SQLModel
|
| 14 |
+
|
| 15 |
+
# this is the Alembic Config object, which provides
|
| 16 |
+
# access to the values within the .ini file in use.
|
| 17 |
+
config = context.config
|
| 18 |
+
|
| 19 |
+
# Interpret the config file for Python logging.
|
| 20 |
+
# This line sets up loggers basically.
|
| 21 |
+
if config.config_file_name is not None:
|
| 22 |
+
fileConfig(config.config_file_name)
|
| 23 |
+
|
| 24 |
+
# add your model's MetaData object here
|
| 25 |
+
# for 'autogenerate' support
|
| 26 |
+
target_metadata = SQLModel.metadata
|
| 27 |
+
|
| 28 |
+
# Set the database URL from settings
|
| 29 |
+
config.set_main_option('sqlalchemy.url', settings.database_url)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def run_migrations_offline() -> None:
|
| 33 |
+
"""Run migrations in 'offline' mode.
|
| 34 |
+
|
| 35 |
+
This configures the context with just a URL
|
| 36 |
+
and not an Engine, though an Engine is acceptable
|
| 37 |
+
here as well. By skipping the Engine creation
|
| 38 |
+
we don't even need a DBAPI to be available.
|
| 39 |
+
|
| 40 |
+
Calls to context.execute() here emit the given string to the
|
| 41 |
+
script output.
|
| 42 |
+
|
| 43 |
+
"""
|
| 44 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 45 |
+
context.configure(
|
| 46 |
+
url=url,
|
| 47 |
+
target_metadata=target_metadata,
|
| 48 |
+
literal_binds=True,
|
| 49 |
+
dialect_opts={"paramstyle": "named"},
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
with context.begin_transaction():
|
| 53 |
+
context.run_migrations()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def run_migrations_online() -> None:
|
| 57 |
+
"""Run migrations in 'online' mode.
|
| 58 |
+
|
| 59 |
+
In this scenario we need to create an Engine
|
| 60 |
+
and associate a connection with the context.
|
| 61 |
+
|
| 62 |
+
"""
|
| 63 |
+
connectable = engine_from_config(
|
| 64 |
+
config.get_section(config.config_ini_section, {}),
|
| 65 |
+
prefix="sqlalchemy.",
|
| 66 |
+
poolclass=pool.NullPool,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
with connectable.connect() as connection:
|
| 70 |
+
context.configure(
|
| 71 |
+
connection=connection, target_metadata=target_metadata
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
with context.begin_transaction():
|
| 75 |
+
context.run_migrations()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if context.is_offline_mode():
|
| 79 |
+
run_migrations_offline()
|
| 80 |
+
else:
|
| 81 |
+
run_migrations_online()
|
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Initial schema: users, todos, sessions, ai_requests tables
|
| 2 |
+
|
| 3 |
+
Revision ID: 001
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2025-01-23
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from sqlalchemy.dialects import postgresql
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '001'
|
| 16 |
+
down_revision: Union[str, None] = None
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
# Create users table
|
| 23 |
+
op.create_table(
|
| 24 |
+
'users',
|
| 25 |
+
sa.Column(
|
| 26 |
+
'id',
|
| 27 |
+
postgresql.UUID(as_uuid=True),
|
| 28 |
+
server_default=sa.text('gen_random_uuid()'),
|
| 29 |
+
nullable=False,
|
| 30 |
+
),
|
| 31 |
+
sa.Column('name', sa.String(length=255), nullable=False),
|
| 32 |
+
sa.Column('email', sa.String(length=255), nullable=False),
|
| 33 |
+
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
| 34 |
+
sa.Column('avatar_url', sa.String(length=500), nullable=True),
|
| 35 |
+
sa.Column(
|
| 36 |
+
'created_at',
|
| 37 |
+
sa.DateTime(),
|
| 38 |
+
server_default=sa.text('CURRENT_TIMESTAMP'),
|
| 39 |
+
nullable=False,
|
| 40 |
+
),
|
| 41 |
+
sa.Column(
|
| 42 |
+
'updated_at',
|
| 43 |
+
sa.DateTime(),
|
| 44 |
+
server_default=sa.text('CURRENT_TIMESTAMP'),
|
| 45 |
+
nullable=False,
|
| 46 |
+
),
|
| 47 |
+
sa.PrimaryKeyConstraint('id'),
|
| 48 |
+
sa.UniqueConstraint('email'),
|
| 49 |
+
)
|
| 50 |
+
op.create_index(op.f('ix_users_id'), 'users', ['id'])
|
| 51 |
+
op.create_index(op.f('ix_users_email'), 'users', ['email'])
|
| 52 |
+
|
| 53 |
+
# Create todos table
|
| 54 |
+
op.create_table(
|
| 55 |
+
'todos',
|
| 56 |
+
sa.Column(
|
| 57 |
+
'id',
|
| 58 |
+
postgresql.UUID(as_uuid=True),
|
| 59 |
+
server_default=sa.text('gen_random_uuid()'),
|
| 60 |
+
nullable=False,
|
| 61 |
+
),
|
| 62 |
+
sa.Column('title', sa.String(length=255), nullable=False),
|
| 63 |
+
sa.Column('description', sa.Text(), nullable=True),
|
| 64 |
+
sa.Column('status', sa.String(length=50), server_default='pending', nullable=False),
|
| 65 |
+
sa.Column('priority', sa.String(length=50), server_default='medium', nullable=False),
|
| 66 |
+
sa.Column('due_date', sa.DateTime(), nullable=True),
|
| 67 |
+
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
| 68 |
+
sa.Column(
|
| 69 |
+
'user_id',
|
| 70 |
+
postgresql.UUID(as_uuid=True),
|
| 71 |
+
nullable=False,
|
| 72 |
+
),
|
| 73 |
+
sa.Column(
|
| 74 |
+
'created_at',
|
| 75 |
+
sa.DateTime(),
|
| 76 |
+
server_default=sa.text('CURRENT_TIMESTAMP'),
|
| 77 |
+
nullable=False,
|
| 78 |
+
),
|
| 79 |
+
sa.Column(
|
| 80 |
+
'updated_at',
|
| 81 |
+
sa.DateTime(),
|
| 82 |
+
server_default=sa.text('CURRENT_TIMESTAMP'),
|
| 83 |
+
nullable=False,
|
| 84 |
+
),
|
| 85 |
+
sa.PrimaryKeyConstraint('id'),
|
| 86 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
| 87 |
+
)
|
| 88 |
+
op.create_index(op.f('ix_todos_id'), 'todos', ['id'], unique=True)
|
| 89 |
+
op.create_index(op.f('ix_todos_user_id'), 'todos', ['user_id'])
|
| 90 |
+
op.create_index('idx_todos_user_status', 'todos', ['user_id', 'status'])
|
| 91 |
+
op.create_index('idx_todos_user_priority', 'todos', ['user_id', 'priority'])
|
| 92 |
+
op.create_index('idx_todos_due_date', 'todos', ['due_date'])
|
| 93 |
+
|
| 94 |
+
# Create sessions table
|
| 95 |
+
op.create_table(
|
| 96 |
+
'sessions',
|
| 97 |
+
sa.Column(
|
| 98 |
+
'id',
|
| 99 |
+
postgresql.UUID(as_uuid=True),
|
| 100 |
+
server_default=sa.text('gen_random_uuid()'),
|
| 101 |
+
nullable=False,
|
| 102 |
+
),
|
| 103 |
+
sa.Column(
|
| 104 |
+
'user_id',
|
| 105 |
+
postgresql.UUID(as_uuid=True),
|
| 106 |
+
nullable=False,
|
| 107 |
+
),
|
| 108 |
+
sa.Column('token', sa.String(length=500), nullable=False),
|
| 109 |
+
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
| 110 |
+
sa.Column(
|
| 111 |
+
'created_at',
|
| 112 |
+
sa.DateTime(),
|
| 113 |
+
server_default=sa.text('CURRENT_TIMESTAMP'),
|
| 114 |
+
nullable=False,
|
| 115 |
+
),
|
| 116 |
+
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
| 117 |
+
sa.Column('user_agent', sa.String(length=500), nullable=True),
|
| 118 |
+
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
| 119 |
+
sa.PrimaryKeyConstraint('id'),
|
| 120 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
| 121 |
+
)
|
| 122 |
+
op.create_index(op.f('ix_sessions_id'), 'sessions', ['id'], unique=True)
|
| 123 |
+
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'])
|
| 124 |
+
op.create_index(op.f('ix_sessions_token'), 'sessions', ['token'])
|
| 125 |
+
op.create_index('idx_sessions_user_expires', 'sessions', ['user_id', 'expires_at'])
|
| 126 |
+
|
| 127 |
+
# Create ai_requests table
|
| 128 |
+
op.create_table(
|
| 129 |
+
'ai_requests',
|
| 130 |
+
sa.Column(
|
| 131 |
+
'id',
|
| 132 |
+
postgresql.UUID(as_uuid=True),
|
| 133 |
+
server_default=sa.text('gen_random_uuid()'),
|
| 134 |
+
nullable=False,
|
| 135 |
+
),
|
| 136 |
+
sa.Column(
|
| 137 |
+
'user_id',
|
| 138 |
+
postgresql.UUID(as_uuid=True),
|
| 139 |
+
nullable=False,
|
| 140 |
+
),
|
| 141 |
+
sa.Column('request_type', sa.String(length=50), nullable=False),
|
| 142 |
+
sa.Column('input_data', sa.Text(), nullable=False),
|
| 143 |
+
sa.Column('output_data', sa.Text(), nullable=True),
|
| 144 |
+
sa.Column('model_used', sa.String(length=100), nullable=False),
|
| 145 |
+
sa.Column('tokens_used', sa.Integer(), nullable=True),
|
| 146 |
+
sa.Column('processing_time_ms', sa.Integer(), nullable=True),
|
| 147 |
+
sa.Column(
|
| 148 |
+
'created_at',
|
| 149 |
+
sa.DateTime(),
|
| 150 |
+
server_default=sa.text('CURRENT_TIMESTAMP'),
|
| 151 |
+
nullable=False,
|
| 152 |
+
),
|
| 153 |
+
sa.PrimaryKeyConstraint('id'),
|
| 154 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
| 155 |
+
)
|
| 156 |
+
op.create_index(op.f('ix_ai_requests_id'), 'ai_requests', ['id'], unique=True)
|
| 157 |
+
op.create_index(op.f('ix_ai_requests_user_id'), 'ai_requests', ['user_id'])
|
| 158 |
+
op.create_index('idx_ai_requests_user_type', 'ai_requests', ['user_id', 'request_type'])
|
| 159 |
+
op.create_index('idx_ai_requests_created', 'ai_requests', ['created_at'])
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def downgrade() -> None:
|
| 163 |
+
# Drop tables in reverse order
|
| 164 |
+
op.drop_index('idx_ai_requests_created', table_name='ai_requests')
|
| 165 |
+
op.drop_index('idx_ai_requests_user_type', table_name='ai_requests')
|
| 166 |
+
op.drop_index(op.f('ix_ai_requests_user_id'), table_name='ai_requests')
|
| 167 |
+
op.drop_index(op.f('ix_ai_requests_id'), table_name='ai_requests')
|
| 168 |
+
op.drop_table('ai_requests')
|
| 169 |
+
|
| 170 |
+
op.drop_index('idx_sessions_user_expires', table_name='sessions')
|
| 171 |
+
op.drop_index(op.f('ix_sessions_token'), table_name='sessions')
|
| 172 |
+
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
| 173 |
+
op.drop_index(op.f('ix_sessions_id'), table_name='sessions')
|
| 174 |
+
op.drop_table('sessions')
|
| 175 |
+
|
| 176 |
+
op.drop_index('idx_todos_due_date', table_name='todos')
|
| 177 |
+
op.drop_index('idx_todos_user_priority', table_name='todos')
|
| 178 |
+
op.drop_index('idx_todos_user_status', table_name='todos')
|
| 179 |
+
op.drop_index(op.f('ix_todos_user_id'), table_name='todos')
|
| 180 |
+
op.drop_index(op.f('ix_todos_id'), table_name='todos')
|
| 181 |
+
op.drop_table('todos')
|
| 182 |
+
|
| 183 |
+
op.drop_index(op.f('ix_users_email'), table_name='users')
|
| 184 |
+
op.drop_index(op.f('ix_users_id'), table_name='users')
|
| 185 |
+
op.drop_table('users')
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const nextJest = require('next/jest');
|
| 2 |
+
|
| 3 |
+
const createJestConfig = nextJest({
|
| 4 |
+
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
| 5 |
+
dir: './',
|
| 6 |
+
});
|
| 7 |
+
|
| 8 |
+
// Add any custom config to be passed to Jest
|
| 9 |
+
const customJestConfig = {
|
| 10 |
+
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
| 11 |
+
testEnvironment: 'jest-environment-jsdom',
|
| 12 |
+
moduleNameMapper: {
|
| 13 |
+
'^@/(.*)$': '<rootDir>/src/$1',
|
| 14 |
+
},
|
| 15 |
+
collectCoverageFrom: [
|
| 16 |
+
'src/**/*.{js,jsx,ts,tsx}',
|
| 17 |
+
'!src/**/*.d.ts',
|
| 18 |
+
'!src/**/*.stories.{js,jsx,ts,tsx}',
|
| 19 |
+
'!src/**/__tests__/**',
|
| 20 |
+
],
|
| 21 |
+
testMatch: [
|
| 22 |
+
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
|
| 23 |
+
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}',
|
| 24 |
+
],
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
| 28 |
+
module.exports = createJestConfig(customJestConfig);
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "todo-app-backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Phase 2 Backend API for Todo SaaS Application"
|
| 5 |
+
authors = [
|
| 6 |
+
{name = "User", email = "user@example.com"}
|
| 7 |
+
]
|
| 8 |
+
readme = "README.md"
|
| 9 |
+
requires-python = ">=3.11"
|
| 10 |
+
classifiers = [
|
| 11 |
+
"Programming Language :: Python :: 3",
|
| 12 |
+
"Programming Language :: Python :: 3.11",
|
| 13 |
+
"Programming Language :: Python :: 3.12",
|
| 14 |
+
"Framework :: FastAPI",
|
| 15 |
+
"Intended Audience :: Developers",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
dependencies = [
|
| 19 |
+
"fastapi>=0.109.0",
|
| 20 |
+
"uvicorn[standard]>=0.27.0",
|
| 21 |
+
"sqlmodel>=0.0.14",
|
| 22 |
+
"alembic>=1.13.0",
|
| 23 |
+
"psycopg[binary]>=3.1.0",
|
| 24 |
+
"python-jose[cryptography]>=3.3.0",
|
| 25 |
+
"passlib[bcrypt]>=1.7.4",
|
| 26 |
+
"python-multipart>=0.0.6",
|
| 27 |
+
"cloudinary>=1.40.0",
|
| 28 |
+
"huggingface-hub>=0.20.0",
|
| 29 |
+
"httpx>=0.26.0",
|
| 30 |
+
"pydantic>=2.5.0",
|
| 31 |
+
"pydantic-settings>=2.1.0",
|
| 32 |
+
"python-dotenv>=1.0.0",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
[project.optional-dependencies]
|
| 36 |
+
dev = [
|
| 37 |
+
"pytest>=7.4.0",
|
| 38 |
+
"pytest-asyncio>=0.23.0",
|
| 39 |
+
"pytest-cov>=4.1.0",
|
| 40 |
+
"black>=23.12.0",
|
| 41 |
+
"ruff>=0.1.0",
|
| 42 |
+
"mypy>=1.8.0",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
[build-system]
|
| 46 |
+
requires = ["setuptools>=68.0"]
|
| 47 |
+
build-backend = "setuptools.build_meta"
|
| 48 |
+
|
| 49 |
+
[tool.black]
|
| 50 |
+
line-length = 100
|
| 51 |
+
target-version = ['py311']
|
| 52 |
+
include = '\.pyi?$'
|
| 53 |
+
extend-exclude = '''
|
| 54 |
+
/(
|
| 55 |
+
# directories
|
| 56 |
+
\.eggs
|
| 57 |
+
| \.git
|
| 58 |
+
| \.hg
|
| 59 |
+
| \.mypy_cache
|
| 60 |
+
| \.tox
|
| 61 |
+
| \.venv
|
| 62 |
+
| build
|
| 63 |
+
| dist
|
| 64 |
+
| alembic/versions
|
| 65 |
+
)/
|
| 66 |
+
'''
|
| 67 |
+
|
| 68 |
+
[tool.ruff]
|
| 69 |
+
line-length = 100
|
| 70 |
+
target-version = "py311"
|
| 71 |
+
select = [
|
| 72 |
+
"E", # pycodestyle errors
|
| 73 |
+
"W", # pycodestyle warnings
|
| 74 |
+
"F", # pyflakes
|
| 75 |
+
"I", # isort
|
| 76 |
+
"B", # flake8-bugbear
|
| 77 |
+
"C4", # flake8-comprehensions
|
| 78 |
+
"UP", # pyupgrade
|
| 79 |
+
]
|
| 80 |
+
ignore = [
|
| 81 |
+
"E501", # line too long (handled by black)
|
| 82 |
+
"B008", # do not perform function calls in argument defaults
|
| 83 |
+
"C901", # too complex
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
[tool.ruff.per-file-ignores]
|
| 87 |
+
"__init__.py" = ["F401"]
|
| 88 |
+
|
| 89 |
+
[tool.mypy]
|
| 90 |
+
python_version = "3.11"
|
| 91 |
+
warn_return_any = true
|
| 92 |
+
warn_unused_configs = true
|
| 93 |
+
disallow_untyped_defs = true
|
| 94 |
+
disallow_incomplete_defs = true
|
| 95 |
+
check_untyped_defs = true
|
| 96 |
+
no_implicit_optional = true
|
| 97 |
+
warn_redundant_casts = true
|
| 98 |
+
warn_unused_ignores = true
|
| 99 |
+
warn_no_return = true
|
| 100 |
+
follow_imports = "normal"
|
| 101 |
+
ignore_missing_imports = true
|
| 102 |
+
|
| 103 |
+
[[tool.mypy.overrides]]
|
| 104 |
+
module = "alembic.*"
|
| 105 |
+
ignore_missing_imports = true
|
| 106 |
+
|
| 107 |
+
[tool.pytest.ini_options]
|
| 108 |
+
minversion = "7.0"
|
| 109 |
+
asyncio_mode = "auto"
|
| 110 |
+
testpaths = ["src/tests"]
|
| 111 |
+
python_files = ["test_*.py"]
|
| 112 |
+
python_classes = ["Test*"]
|
| 113 |
+
python_functions = ["test_*"]
|
| 114 |
+
addopts = [
|
| 115 |
+
"-ra",
|
| 116 |
+
"--strict-markers",
|
| 117 |
+
"--strict-config",
|
| 118 |
+
"--cov=src",
|
| 119 |
+
"--cov-report=term-missing",
|
| 120 |
+
"--cov-report=html",
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
[tool.coverage.run]
|
| 124 |
+
source = ["src"]
|
| 125 |
+
omit = [
|
| 126 |
+
"*/tests/*",
|
| 127 |
+
"*/alembic/versions/*",
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
[tool.coverage.report]
|
| 131 |
+
exclude_lines = [
|
| 132 |
+
"pragma: no cover",
|
| 133 |
+
"def __repr__",
|
| 134 |
+
"raise AssertionError",
|
| 135 |
+
"raise NotImplementedError",
|
| 136 |
+
"if __name__ == .__main__.:",
|
| 137 |
+
"if TYPE_CHECKING:",
|
| 138 |
+
"@abstractmethod",
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
[project.scripts]
|
| 142 |
+
# Development commands
|
| 143 |
+
dev = "uvicorn src.main:app --reload --port 8000 --host 0.0.0.0"
|
| 144 |
+
test = "pytest"
|
| 145 |
+
test-cov = "pytest --cov=src --cov-report=html"
|
| 146 |
+
|
| 147 |
+
# Database migration commands
|
| 148 |
+
db-upgrade = "alembic upgrade head"
|
| 149 |
+
db-downgrade = "alembic downgrade -1"
|
| 150 |
+
db-migration = "alembic revision --autogenerate -m"
|
| 151 |
+
|
| 152 |
+
# Code quality commands
|
| 153 |
+
format = "black ."
|
| 154 |
+
lint = "ruff check ."
|
| 155 |
+
lint-fix = "ruff check . --fix"
|
| 156 |
+
type-check = "mypy ."
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI API routes.
|
| 3 |
+
|
| 4 |
+
Provides endpoints for AI-powered todo features.
|
| 5 |
+
"""
|
| 6 |
+
from typing import List
|
| 7 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from src.api.deps import get_current_user_id, get_db
|
| 11 |
+
from src.services.ai_service import ai_service
|
| 12 |
+
from sqlmodel import Session, select
|
| 13 |
+
from src.models.todo import Todo
|
| 14 |
+
from uuid import UUID
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AIGenerateRequest(BaseModel):
|
| 18 |
+
"""Request schema for AI todo generation."""
|
| 19 |
+
|
| 20 |
+
goal: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AIGenerateResponse(BaseModel):
|
| 24 |
+
"""Response schema for AI todo generation."""
|
| 25 |
+
|
| 26 |
+
todos: List[dict]
|
| 27 |
+
message: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AISummarizeResponse(BaseModel):
|
| 31 |
+
"""Response schema for AI todo summarization."""
|
| 32 |
+
|
| 33 |
+
summary: str
|
| 34 |
+
breakdown: dict
|
| 35 |
+
urgent_todos: List[str]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class AIPrioritizeResponse(BaseModel):
|
| 39 |
+
"""Response schema for AI todo prioritization."""
|
| 40 |
+
|
| 41 |
+
prioritized_todos: List[dict]
|
| 42 |
+
message: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
router = APIRouter()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.post(
|
| 49 |
+
'/generate-todo',
|
| 50 |
+
response_model=AIGenerateResponse,
|
| 51 |
+
summary='Generate todos with AI',
|
| 52 |
+
description='Generate todo suggestions from a goal using AI',
|
| 53 |
+
)
|
| 54 |
+
async def generate_todos(
|
| 55 |
+
request: AIGenerateRequest,
|
| 56 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 57 |
+
):
|
| 58 |
+
"""Generate todos from a goal using AI."""
|
| 59 |
+
try:
|
| 60 |
+
result = ai_service.generate_todos(request.goal)
|
| 61 |
+
return AIGenerateResponse(**result)
|
| 62 |
+
except ValueError as e:
|
| 63 |
+
raise HTTPException(
|
| 64 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 65 |
+
detail=str(e),
|
| 66 |
+
)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 70 |
+
detail=f"AI service error: {str(e)}",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post(
|
| 75 |
+
'/summarize',
|
| 76 |
+
response_model=AISummarizeResponse,
|
| 77 |
+
summary='Summarize todos with AI',
|
| 78 |
+
description='Get an AI-powered summary of todos',
|
| 79 |
+
)
|
| 80 |
+
async def summarize_todos(
|
| 81 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 82 |
+
db: Session = Depends(get_db),
|
| 83 |
+
):
|
| 84 |
+
"""Summarize todos using AI."""
|
| 85 |
+
try:
|
| 86 |
+
# Get user's todos
|
| 87 |
+
query = select(Todo).where(Todo.user_id == UUID(current_user_id))
|
| 88 |
+
todos = db.exec(query).all()
|
| 89 |
+
|
| 90 |
+
# Convert to dict format
|
| 91 |
+
todos_dict = [
|
| 92 |
+
{
|
| 93 |
+
"title": t.title,
|
| 94 |
+
"description": t.description,
|
| 95 |
+
"priority": t.priority.value,
|
| 96 |
+
"due_date": t.due_date.isoformat() if t.due_date else None,
|
| 97 |
+
}
|
| 98 |
+
for t in todos
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
result = ai_service.summarize_todos(todos_dict)
|
| 102 |
+
return AISummarizeResponse(**result)
|
| 103 |
+
|
| 104 |
+
except ValueError as e:
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 107 |
+
detail=str(e),
|
| 108 |
+
)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 112 |
+
detail=f"AI service error: {str(e)}",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.post(
|
| 117 |
+
'/prioritize',
|
| 118 |
+
response_model=AIPrioritizeResponse,
|
| 119 |
+
summary='Prioritize todos with AI',
|
| 120 |
+
description='Get AI-powered todo prioritization',
|
| 121 |
+
)
|
| 122 |
+
async def prioritize_todos(
|
| 123 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 124 |
+
db: Session = Depends(get_db),
|
| 125 |
+
):
|
| 126 |
+
"""Prioritize todos using AI."""
|
| 127 |
+
try:
|
| 128 |
+
# Get user's todos
|
| 129 |
+
query = select(Todo).where(Todo.user_id == UUID(current_user_id))
|
| 130 |
+
todos = db.exec(query).all()
|
| 131 |
+
|
| 132 |
+
# Convert to dict format with IDs
|
| 133 |
+
todos_dict = [
|
| 134 |
+
{
|
| 135 |
+
"id": str(t.id),
|
| 136 |
+
"title": t.title,
|
| 137 |
+
"description": t.description,
|
| 138 |
+
"priority": t.priority.value,
|
| 139 |
+
"due_date": t.due_date.isoformat() if t.due_date else None,
|
| 140 |
+
}
|
| 141 |
+
for t in todos
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
result = ai_service.prioritize_todos(todos_dict)
|
| 145 |
+
return AIPrioritizeResponse(**result)
|
| 146 |
+
|
| 147 |
+
except ValueError as e:
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 150 |
+
detail=str(e),
|
| 151 |
+
)
|
| 152 |
+
except Exception as e:
|
| 153 |
+
raise HTTPException(
|
| 154 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 155 |
+
detail=f"AI service error: {str(e)}",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
__all__ = ['router']
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication API routes.
|
| 3 |
+
|
| 4 |
+
Provides endpoints for user registration, login, logout, and token verification.
|
| 5 |
+
"""
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
| 7 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 8 |
+
from sqlmodel import Session
|
| 9 |
+
|
| 10 |
+
from src.api.deps import get_current_user, get_db
|
| 11 |
+
from src.core.config import settings
|
| 12 |
+
from src.models.user import User
|
| 13 |
+
from src.schemas.auth import AuthResponse, LoginRequest, SignupRequest
|
| 14 |
+
from src.schemas.user import UserResponse
|
| 15 |
+
from src.services.auth_service import (
|
| 16 |
+
authenticate_user,
|
| 17 |
+
create_user,
|
| 18 |
+
create_user_token,
|
| 19 |
+
get_user_by_email,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
router = APIRouter()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post(
|
| 26 |
+
'/signup',
|
| 27 |
+
response_model=AuthResponse,
|
| 28 |
+
status_code=status.HTTP_201_CREATED,
|
| 29 |
+
summary='Register a new user',
|
| 30 |
+
description='Create a new user account with email and password',
|
| 31 |
+
)
|
| 32 |
+
async def signup(
|
| 33 |
+
user_data: SignupRequest,
|
| 34 |
+
db: Session = Depends(get_db),
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
Register a new user.
|
| 38 |
+
|
| 39 |
+
Validates email format, checks for duplicate emails,
|
| 40 |
+
validates password strength, and creates a new user.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
user_data: User registration data (name, email, password)
|
| 44 |
+
db: Database session
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
AuthResponse: JWT token and user information
|
| 48 |
+
|
| 49 |
+
Raises:
|
| 50 |
+
HTTPException 400: If validation fails or email already exists
|
| 51 |
+
"""
|
| 52 |
+
print(f"DEBUG: Signup request received: {user_data.dict()}")
|
| 53 |
+
try:
|
| 54 |
+
# Create user
|
| 55 |
+
user = create_user(db, user_data)
|
| 56 |
+
|
| 57 |
+
# Generate JWT token
|
| 58 |
+
access_token = create_user_token(user.id)
|
| 59 |
+
|
| 60 |
+
# Return response
|
| 61 |
+
return AuthResponse(
|
| 62 |
+
access_token=access_token,
|
| 63 |
+
token_type='bearer',
|
| 64 |
+
user={
|
| 65 |
+
'id': str(user.id),
|
| 66 |
+
'name': user.name,
|
| 67 |
+
'email': user.email,
|
| 68 |
+
'avatar_url': user.avatar_url,
|
| 69 |
+
'created_at': user.created_at.isoformat(),
|
| 70 |
+
'updated_at': user.updated_at.isoformat(),
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
except ValueError as e:
|
| 75 |
+
# Handle validation errors
|
| 76 |
+
error_msg = str(e)
|
| 77 |
+
|
| 78 |
+
if 'already registered' in error_msg:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 81 |
+
detail='Email is already registered. Please use a different email or login.',
|
| 82 |
+
)
|
| 83 |
+
elif 'Password' in error_msg:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 86 |
+
detail=error_msg,
|
| 87 |
+
)
|
| 88 |
+
else:
|
| 89 |
+
raise HTTPException(
|
| 90 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 91 |
+
detail='Validation failed: ' + error_msg,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
# Handle unexpected errors
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 98 |
+
detail='An error occurred while creating your account. Please try again.',
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post(
|
| 103 |
+
'/login',
|
| 104 |
+
response_model=AuthResponse,
|
| 105 |
+
summary='Login user',
|
| 106 |
+
description='Authenticate user with email and password',
|
| 107 |
+
)
|
| 108 |
+
async def login(
|
| 109 |
+
user_data: LoginRequest,
|
| 110 |
+
response: Response,
|
| 111 |
+
db: Session = Depends(get_db),
|
| 112 |
+
):
|
| 113 |
+
"""
|
| 114 |
+
Login a user.
|
| 115 |
+
|
| 116 |
+
Validates credentials and returns a JWT token.
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
user_data: Login credentials (email, password)
|
| 120 |
+
response: FastAPI response object
|
| 121 |
+
db: Database session
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
AuthResponse: JWT token and user information
|
| 125 |
+
|
| 126 |
+
Raises:
|
| 127 |
+
HTTPException 401: If credentials are invalid
|
| 128 |
+
"""
|
| 129 |
+
print(f"DEBUG: Login request received: email={user_data.email}")
|
| 130 |
+
# Authenticate user
|
| 131 |
+
user = authenticate_user(db, user_data.email, user_data.password)
|
| 132 |
+
|
| 133 |
+
if not user:
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 136 |
+
detail='Invalid email or password',
|
| 137 |
+
headers={'WWW-Authenticate': 'Bearer'},
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Generate JWT token
|
| 141 |
+
access_token = create_user_token(user.id)
|
| 142 |
+
|
| 143 |
+
# Set httpOnly cookie (optional, for additional security)
|
| 144 |
+
response.set_cookie(
|
| 145 |
+
key='access_token',
|
| 146 |
+
value=access_token,
|
| 147 |
+
httponly=True,
|
| 148 |
+
secure=not settings.is_development, # HTTPS in production
|
| 149 |
+
samesite='lax',
|
| 150 |
+
max_age=settings.jwt_expiration_days * 24 * 60 * 60, # Convert days to seconds
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Return response
|
| 154 |
+
return AuthResponse(
|
| 155 |
+
access_token=access_token,
|
| 156 |
+
token_type='bearer',
|
| 157 |
+
user={
|
| 158 |
+
'id': str(user.id),
|
| 159 |
+
'name': user.name,
|
| 160 |
+
'email': user.email,
|
| 161 |
+
'avatar_url': user.avatar_url,
|
| 162 |
+
'created_at': user.created_at.isoformat(),
|
| 163 |
+
'updated_at': user.updated_at.isoformat(),
|
| 164 |
+
},
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@router.post(
|
| 169 |
+
'/logout',
|
| 170 |
+
summary='Logout user',
|
| 171 |
+
description='Logout user and clear authentication token',
|
| 172 |
+
)
|
| 173 |
+
async def logout(response: Response):
|
| 174 |
+
"""
|
| 175 |
+
Logout a user.
|
| 176 |
+
|
| 177 |
+
Clears the authentication cookie.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
response: FastAPI response object
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
dict: Logout confirmation message
|
| 184 |
+
"""
|
| 185 |
+
# Clear authentication cookie
|
| 186 |
+
response.delete_cookie('access_token')
|
| 187 |
+
|
| 188 |
+
return {'message': 'Successfully logged out'}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.get(
|
| 192 |
+
'/me',
|
| 193 |
+
response_model=UserResponse,
|
| 194 |
+
summary='Get current user',
|
| 195 |
+
description='Get information about the currently authenticated user',
|
| 196 |
+
)
|
| 197 |
+
async def get_current_user_info(
|
| 198 |
+
current_user: User = Depends(get_current_user),
|
| 199 |
+
):
|
| 200 |
+
"""
|
| 201 |
+
Get current authenticated user.
|
| 202 |
+
|
| 203 |
+
Requires valid JWT token in Authorization header.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
current_user: Current user from dependency
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
UserResponse: Current user information
|
| 210 |
+
"""
|
| 211 |
+
return current_user
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# OAuth2 compatible endpoint for token generation
|
| 215 |
+
@router.post(
|
| 216 |
+
'/token',
|
| 217 |
+
response_model=AuthResponse,
|
| 218 |
+
summary='Get access token',
|
| 219 |
+
description='OAuth2 compatible endpoint to get access token',
|
| 220 |
+
)
|
| 221 |
+
async def get_access_token(
|
| 222 |
+
response: Response,
|
| 223 |
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
| 224 |
+
db: Session = Depends(get_db),
|
| 225 |
+
):
|
| 226 |
+
"""
|
| 227 |
+
OAuth2 compatible token endpoint.
|
| 228 |
+
|
| 229 |
+
Used by OAuth2 clients to obtain access tokens.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
form_data: OAuth2 password request form
|
| 233 |
+
response: FastAPI response object
|
| 234 |
+
db: Database session
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
AuthResponse: JWT token and user information
|
| 238 |
+
"""
|
| 239 |
+
# Use login logic
|
| 240 |
+
user = authenticate_user(db, form_data.username, form_data.password)
|
| 241 |
+
|
| 242 |
+
if not user:
|
| 243 |
+
raise HTTPException(
|
| 244 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 245 |
+
detail='Incorrect email or password',
|
| 246 |
+
headers={'WWW-Authenticate': 'Bearer'},
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
access_token = create_user_token(user.id)
|
| 250 |
+
|
| 251 |
+
# Set cookie
|
| 252 |
+
response.set_cookie(
|
| 253 |
+
key='access_token',
|
| 254 |
+
value=access_token,
|
| 255 |
+
httponly=True,
|
| 256 |
+
secure=not settings.is_development,
|
| 257 |
+
samesite='lax',
|
| 258 |
+
max_age=settings.jwt_expiration_days * 24 * 60 * 60,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
return AuthResponse(
|
| 262 |
+
access_token=access_token,
|
| 263 |
+
token_type='bearer',
|
| 264 |
+
user={
|
| 265 |
+
'id': str(user.id),
|
| 266 |
+
'name': user.name,
|
| 267 |
+
'email': user.email,
|
| 268 |
+
'avatar_url': user.avatar_url,
|
| 269 |
+
'created_at': user.created_at.isoformat(),
|
| 270 |
+
'updated_at': user.updated_at.isoformat(),
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Export router
|
| 276 |
+
__all__ = ['router']
|
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI dependencies for database sessions and authentication.
|
| 3 |
+
|
| 4 |
+
Provides reusable dependency functions for injecting database sessions
|
| 5 |
+
and authenticated users into route handlers.
|
| 6 |
+
"""
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import Depends, HTTPException, status
|
| 10 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 11 |
+
from sqlmodel import Session, select
|
| 12 |
+
|
| 13 |
+
from src.core.database import get_session
|
| 14 |
+
from src.core.security import TokenData
|
| 15 |
+
from src.models.user import User
|
| 16 |
+
|
| 17 |
+
# HTTP Bearer token scheme for JWT extraction
|
| 18 |
+
security = HTTPBearer(auto_error=False)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def get_db(
|
| 22 |
+
session: Session = Depends(get_session),
|
| 23 |
+
) -> Session:
|
| 24 |
+
"""
|
| 25 |
+
Dependency for getting database session.
|
| 26 |
+
|
| 27 |
+
This is a passthrough dependency that allows for future enhancements
|
| 28 |
+
like request-scoped sessions or transaction management.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
session: Database session from get_session
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Session: Database session
|
| 35 |
+
|
| 36 |
+
Example:
|
| 37 |
+
@app.get("/users")
|
| 38 |
+
def get_users(db: Session = Depends(get_db)):
|
| 39 |
+
return db.exec(select(User)).all()
|
| 40 |
+
"""
|
| 41 |
+
return session
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def get_current_user(
|
| 45 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 46 |
+
db: Session = Depends(get_db),
|
| 47 |
+
) -> User:
|
| 48 |
+
"""
|
| 49 |
+
Dependency for getting authenticated user from JWT token.
|
| 50 |
+
|
| 51 |
+
Extracts JWT token from Authorization header, validates it,
|
| 52 |
+
and returns the corresponding user.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
credentials: HTTP Bearer credentials from Authorization header
|
| 56 |
+
db: Database session
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
User: Authenticated user
|
| 60 |
+
|
| 61 |
+
Raises:
|
| 62 |
+
HTTPException: If token is missing, invalid, or user not found
|
| 63 |
+
|
| 64 |
+
Example:
|
| 65 |
+
@app.get("/me")
|
| 66 |
+
def get_me(current_user: User = Depends(get_current_user)):
|
| 67 |
+
return current_user
|
| 68 |
+
"""
|
| 69 |
+
# Check if credentials are provided
|
| 70 |
+
if credentials is None:
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 73 |
+
detail='Not authenticated',
|
| 74 |
+
headers={'WWW-Authenticate': 'Bearer'},
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Extract and decode token
|
| 78 |
+
token = credentials.credentials
|
| 79 |
+
token_data = TokenData.from_token(token)
|
| 80 |
+
|
| 81 |
+
if token_data is None:
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 84 |
+
detail='Invalid authentication credentials',
|
| 85 |
+
headers={'WWW-Authenticate': 'Bearer'},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Check if token is expired
|
| 89 |
+
if token_data.is_expired():
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 92 |
+
detail='Token has expired',
|
| 93 |
+
headers={'WWW-Authenticate': 'Bearer'},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Get user from database
|
| 97 |
+
user = db.get(User, token_data.user_id)
|
| 98 |
+
|
| 99 |
+
if user is None:
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 102 |
+
detail='User not found',
|
| 103 |
+
headers={'WWW-Authenticate': 'Bearer'},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return user
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def get_current_user_optional(
|
| 110 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 111 |
+
db: Session = Depends(get_db),
|
| 112 |
+
) -> Optional[User]:
|
| 113 |
+
"""
|
| 114 |
+
Optional authentication dependency.
|
| 115 |
+
|
| 116 |
+
Returns the authenticated user if a valid token is provided,
|
| 117 |
+
otherwise returns None. Useful for routes that work for both
|
| 118 |
+
authenticated and anonymous users.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
credentials: HTTP Bearer credentials from Authorization header
|
| 122 |
+
db: Database session
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Optional[User]: Authenticated user or None
|
| 126 |
+
|
| 127 |
+
Example:
|
| 128 |
+
@app.get("/public-data")
|
| 129 |
+
def get_public_data(user: Optional[User] = Depends(get_current_user_optional)):
|
| 130 |
+
if user:
|
| 131 |
+
return {'data': '...', 'user': user.email}
|
| 132 |
+
return {'data': '...'}
|
| 133 |
+
"""
|
| 134 |
+
if credentials is None:
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
token = credentials.credentials
|
| 138 |
+
token_data = TokenData.from_token(token)
|
| 139 |
+
|
| 140 |
+
if token_data is None or token_data.is_expired():
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
user = db.get(User, token_data.user_id)
|
| 144 |
+
return user
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
async def get_current_user_id(
|
| 148 |
+
current_user: User = Depends(get_current_user),
|
| 149 |
+
) -> str:
|
| 150 |
+
"""
|
| 151 |
+
Dependency for getting authenticated user's ID as a string.
|
| 152 |
+
|
| 153 |
+
This is a convenience wrapper around get_current_user that extracts
|
| 154 |
+
just the user ID as a string, which is commonly needed in API routes.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
current_user: Authenticated user from get_current_user
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
str: User ID as a string
|
| 161 |
+
|
| 162 |
+
Example:
|
| 163 |
+
@app.get("/todos")
|
| 164 |
+
def list_todos(user_id: str = Depends(get_current_user_id)):
|
| 165 |
+
return {"user_id": user_id}
|
| 166 |
+
"""
|
| 167 |
+
return str(current_user.id)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# Export for use in other modules
|
| 171 |
+
__all__ = ['get_db', 'get_current_user', 'get_current_user_optional', 'get_current_user_id']
|
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Todo API routes.
|
| 3 |
+
|
| 4 |
+
Provides endpoints for todo CRUD operations.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from uuid import UUID
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException, Query, status, Depends
|
| 11 |
+
from sqlmodel import Session, select
|
| 12 |
+
|
| 13 |
+
from src.api.deps import get_current_user_id, get_db
|
| 14 |
+
from src.models.todo import Priority, Status, Todo
|
| 15 |
+
from src.schemas.todo import TodoCreateRequest, TodoResponse, TodoUpdateRequest
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get(
|
| 21 |
+
'/',
|
| 22 |
+
response_model=list[TodoResponse],
|
| 23 |
+
summary='List todos',
|
| 24 |
+
description='Get all todos for the current user with optional filtering',
|
| 25 |
+
)
|
| 26 |
+
async def list_todos(
|
| 27 |
+
skip: int = Query(0, ge=0, description='Number of todos to skip'),
|
| 28 |
+
limit: int = Query(20, ge=1, le=100, description='Number of todos to return'),
|
| 29 |
+
status_filter: Optional[str] = Query(None, alias='status', description='Filter by status'),
|
| 30 |
+
priority: Optional[str] = Query(None, description='Filter by priority'),
|
| 31 |
+
search: Optional[str] = Query(None, description='Search in title and description'),
|
| 32 |
+
sort_by: str = Query('created_at', description='Sort by field'),
|
| 33 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 34 |
+
db: Session = Depends(get_db),
|
| 35 |
+
):
|
| 36 |
+
"""List todos for the current user with filtering and pagination."""
|
| 37 |
+
# Build base query with user isolation
|
| 38 |
+
query = select(Todo).where(Todo.user_id == UUID(current_user_id))
|
| 39 |
+
|
| 40 |
+
# Apply filters
|
| 41 |
+
if status_filter:
|
| 42 |
+
query = query.where(Todo.status == Status(status_filter))
|
| 43 |
+
if priority:
|
| 44 |
+
query = query.where(Todo.priority == Priority(priority))
|
| 45 |
+
if search:
|
| 46 |
+
search_pattern = f'%{search}%'
|
| 47 |
+
query = query.where(
|
| 48 |
+
(Todo.title.ilike(search_pattern)) | (Todo.description.ilike(search_pattern))
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Apply sorting
|
| 52 |
+
if sort_by == 'created_at':
|
| 53 |
+
query = query.order_by(Todo.created_at.desc())
|
| 54 |
+
elif sort_by == 'due_date':
|
| 55 |
+
query = query.order_by(Todo.due_date.asc().nulls_last())
|
| 56 |
+
elif sort_by == 'priority':
|
| 57 |
+
query = query.order_by(Todo.priority.desc())
|
| 58 |
+
|
| 59 |
+
# Apply pagination
|
| 60 |
+
query = query.offset(skip).limit(limit)
|
| 61 |
+
|
| 62 |
+
# Execute query
|
| 63 |
+
todos = db.exec(query).all()
|
| 64 |
+
|
| 65 |
+
return [
|
| 66 |
+
TodoResponse(
|
| 67 |
+
id=str(todo.id),
|
| 68 |
+
user_id=str(todo.user_id),
|
| 69 |
+
title=todo.title,
|
| 70 |
+
description=todo.description,
|
| 71 |
+
status=todo.status.value,
|
| 72 |
+
priority=todo.priority.value,
|
| 73 |
+
tags=todo.tags,
|
| 74 |
+
due_date=todo.due_date.isoformat() if todo.due_date else None,
|
| 75 |
+
created_at=todo.created_at.isoformat(),
|
| 76 |
+
updated_at=todo.updated_at.isoformat(),
|
| 77 |
+
)
|
| 78 |
+
for todo in todos
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.post(
|
| 83 |
+
'/',
|
| 84 |
+
response_model=TodoResponse,
|
| 85 |
+
status_code=status.HTTP_201_CREATED,
|
| 86 |
+
summary='Create todo',
|
| 87 |
+
description='Create a new todo',
|
| 88 |
+
)
|
| 89 |
+
async def create_todo(
|
| 90 |
+
todo_data: TodoCreateRequest,
|
| 91 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 92 |
+
db: Session = Depends(get_db),
|
| 93 |
+
):
|
| 94 |
+
"""Create a new todo for the current user."""
|
| 95 |
+
todo = Todo(
|
| 96 |
+
title=todo_data.title,
|
| 97 |
+
description=todo_data.description,
|
| 98 |
+
priority=Priority(todo_data.priority) if todo_data.priority else Priority.MEDIUM,
|
| 99 |
+
due_date=todo_data.due_date,
|
| 100 |
+
tags=todo_data.tags,
|
| 101 |
+
user_id=UUID(current_user_id),
|
| 102 |
+
status=Status.PENDING,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
db.add(todo)
|
| 106 |
+
db.commit()
|
| 107 |
+
db.refresh(todo)
|
| 108 |
+
|
| 109 |
+
return TodoResponse(
|
| 110 |
+
id=str(todo.id),
|
| 111 |
+
user_id=str(todo.user_id),
|
| 112 |
+
title=todo.title,
|
| 113 |
+
description=todo.description,
|
| 114 |
+
status=todo.status.value,
|
| 115 |
+
priority=todo.priority.value,
|
| 116 |
+
tags=todo.tags,
|
| 117 |
+
due_date=todo.due_date.isoformat() if todo.due_date else None,
|
| 118 |
+
created_at=todo.created_at.isoformat(),
|
| 119 |
+
updated_at=todo.updated_at.isoformat(),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# IMPORTANT: More specific routes must come BEFORE parameterized routes
|
| 124 |
+
@router.post(
|
| 125 |
+
'/{todo_id}/toggle',
|
| 126 |
+
response_model=TodoResponse,
|
| 127 |
+
summary='Toggle todo completion (POST)',
|
| 128 |
+
description='Toggle todo completion status - POST method for frontend compatibility',
|
| 129 |
+
)
|
| 130 |
+
async def toggle_todo_post(
|
| 131 |
+
todo_id: str,
|
| 132 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 133 |
+
db: Session = Depends(get_db),
|
| 134 |
+
):
|
| 135 |
+
"""Toggle todo completion status using POST method."""
|
| 136 |
+
query = select(Todo).where(
|
| 137 |
+
Todo.id == UUID(todo_id),
|
| 138 |
+
Todo.user_id == UUID(current_user_id)
|
| 139 |
+
)
|
| 140 |
+
todo = db.exec(query).first()
|
| 141 |
+
|
| 142 |
+
if not todo:
|
| 143 |
+
raise HTTPException(
|
| 144 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 145 |
+
detail='Todo not found',
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# Toggle status - flip between completed and pending
|
| 149 |
+
if todo.status == Status.PENDING:
|
| 150 |
+
todo.status = Status.COMPLETED
|
| 151 |
+
if not todo.completed_at:
|
| 152 |
+
todo.completed_at = datetime.utcnow()
|
| 153 |
+
else:
|
| 154 |
+
todo.status = Status.PENDING
|
| 155 |
+
todo.completed_at = None
|
| 156 |
+
|
| 157 |
+
db.add(todo)
|
| 158 |
+
db.commit()
|
| 159 |
+
db.refresh(todo)
|
| 160 |
+
|
| 161 |
+
return TodoResponse(
|
| 162 |
+
id=str(todo.id),
|
| 163 |
+
user_id=str(todo.user_id),
|
| 164 |
+
title=todo.title,
|
| 165 |
+
description=todo.description,
|
| 166 |
+
status=todo.status.value,
|
| 167 |
+
priority=todo.priority.value,
|
| 168 |
+
tags=todo.tags,
|
| 169 |
+
due_date=todo.due_date.isoformat() if todo.due_date else None,
|
| 170 |
+
created_at=todo.created_at.isoformat(),
|
| 171 |
+
updated_at=todo.updated_at.isoformat(),
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@router.patch(
|
| 176 |
+
'/{todo_id}/complete',
|
| 177 |
+
response_model=TodoResponse,
|
| 178 |
+
summary='Toggle todo completion',
|
| 179 |
+
description='Toggle todo completion status',
|
| 180 |
+
)
|
| 181 |
+
async def toggle_complete(
|
| 182 |
+
todo_id: str,
|
| 183 |
+
completed: bool = True,
|
| 184 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 185 |
+
db: Session = Depends(get_db),
|
| 186 |
+
):
|
| 187 |
+
"""Toggle todo completion status."""
|
| 188 |
+
query = select(Todo).where(
|
| 189 |
+
Todo.id == UUID(todo_id),
|
| 190 |
+
Todo.user_id == UUID(current_user_id)
|
| 191 |
+
)
|
| 192 |
+
todo = db.exec(query).first()
|
| 193 |
+
|
| 194 |
+
if not todo:
|
| 195 |
+
raise HTTPException(
|
| 196 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 197 |
+
detail='Todo not found',
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Toggle status
|
| 201 |
+
todo.status = Status.COMPLETED if completed else Status.PENDING
|
| 202 |
+
if completed and not todo.completed_at:
|
| 203 |
+
todo.completed_at = datetime.utcnow()
|
| 204 |
+
elif not completed:
|
| 205 |
+
todo.completed_at = None
|
| 206 |
+
|
| 207 |
+
db.add(todo)
|
| 208 |
+
db.commit()
|
| 209 |
+
db.refresh(todo)
|
| 210 |
+
|
| 211 |
+
return TodoResponse(
|
| 212 |
+
id=str(todo.id),
|
| 213 |
+
user_id=str(todo.user_id),
|
| 214 |
+
title=todo.title,
|
| 215 |
+
description=todo.description,
|
| 216 |
+
status=todo.status.value,
|
| 217 |
+
priority=todo.priority.value,
|
| 218 |
+
tags=todo.tags,
|
| 219 |
+
due_date=todo.due_date.isoformat() if todo.due_date else None,
|
| 220 |
+
created_at=todo.created_at.isoformat(),
|
| 221 |
+
updated_at=todo.updated_at.isoformat(),
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.get(
|
| 226 |
+
'/{todo_id}',
|
| 227 |
+
response_model=TodoResponse,
|
| 228 |
+
summary='Get todo',
|
| 229 |
+
description='Get a specific todo by ID',
|
| 230 |
+
)
|
| 231 |
+
async def get_todo(
|
| 232 |
+
todo_id: str,
|
| 233 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 234 |
+
db: Session = Depends(get_db),
|
| 235 |
+
):
|
| 236 |
+
"""Get a specific todo."""
|
| 237 |
+
query = select(Todo).where(
|
| 238 |
+
Todo.id == UUID(todo_id),
|
| 239 |
+
Todo.user_id == UUID(current_user_id)
|
| 240 |
+
)
|
| 241 |
+
todo = db.exec(query).first()
|
| 242 |
+
|
| 243 |
+
if not todo:
|
| 244 |
+
raise HTTPException(
|
| 245 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 246 |
+
detail='Todo not found',
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return TodoResponse(
|
| 250 |
+
id=str(todo.id),
|
| 251 |
+
user_id=str(todo.user_id),
|
| 252 |
+
title=todo.title,
|
| 253 |
+
description=todo.description,
|
| 254 |
+
status=todo.status.value,
|
| 255 |
+
priority=todo.priority.value,
|
| 256 |
+
tags=todo.tags,
|
| 257 |
+
due_date=todo.due_date.isoformat() if todo.due_date else None,
|
| 258 |
+
created_at=todo.created_at.isoformat(),
|
| 259 |
+
updated_at=todo.updated_at.isoformat(),
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@router.put(
|
| 264 |
+
'/{todo_id}',
|
| 265 |
+
response_model=TodoResponse,
|
| 266 |
+
summary='Update todo',
|
| 267 |
+
description='Update a todo',
|
| 268 |
+
)
|
| 269 |
+
async def update_todo(
|
| 270 |
+
todo_id: str,
|
| 271 |
+
todo_data: TodoUpdateRequest,
|
| 272 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 273 |
+
db: Session = Depends(get_db),
|
| 274 |
+
):
|
| 275 |
+
"""Update a todo."""
|
| 276 |
+
query = select(Todo).where(
|
| 277 |
+
Todo.id == UUID(todo_id),
|
| 278 |
+
Todo.user_id == UUID(current_user_id)
|
| 279 |
+
)
|
| 280 |
+
todo = db.exec(query).first()
|
| 281 |
+
|
| 282 |
+
if not todo:
|
| 283 |
+
raise HTTPException(
|
| 284 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 285 |
+
detail='Todo not found',
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# Update fields
|
| 289 |
+
if todo_data.title is not None:
|
| 290 |
+
todo.title = todo_data.title
|
| 291 |
+
if todo_data.description is not None:
|
| 292 |
+
todo.description = todo_data.description
|
| 293 |
+
if todo_data.priority is not None:
|
| 294 |
+
todo.priority = Priority(todo_data.priority)
|
| 295 |
+
if todo_data.due_date is not None:
|
| 296 |
+
todo.due_date = todo_data.due_date
|
| 297 |
+
if todo_data.tags is not None:
|
| 298 |
+
todo.tags = todo_data.tags
|
| 299 |
+
|
| 300 |
+
db.add(todo)
|
| 301 |
+
db.commit()
|
| 302 |
+
db.refresh(todo)
|
| 303 |
+
|
| 304 |
+
return TodoResponse(
|
| 305 |
+
id=str(todo.id),
|
| 306 |
+
user_id=str(todo.user_id),
|
| 307 |
+
title=todo.title,
|
| 308 |
+
description=todo.description,
|
| 309 |
+
status=todo.status.value,
|
| 310 |
+
priority=todo.priority.value,
|
| 311 |
+
tags=todo.tags,
|
| 312 |
+
due_date=todo.due_date.isoformat() if todo.due_date else None,
|
| 313 |
+
created_at=todo.created_at.isoformat(),
|
| 314 |
+
updated_at=todo.updated_at.isoformat(),
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@router.delete(
|
| 319 |
+
'/{todo_id}',
|
| 320 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 321 |
+
summary='Delete todo',
|
| 322 |
+
description='Delete a todo',
|
| 323 |
+
)
|
| 324 |
+
async def delete_todo(
|
| 325 |
+
todo_id: str,
|
| 326 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 327 |
+
db: Session = Depends(get_db),
|
| 328 |
+
):
|
| 329 |
+
"""Delete a todo."""
|
| 330 |
+
query = select(Todo).where(
|
| 331 |
+
Todo.id == UUID(todo_id),
|
| 332 |
+
Todo.user_id == UUID(current_user_id)
|
| 333 |
+
)
|
| 334 |
+
todo = db.exec(query).first()
|
| 335 |
+
|
| 336 |
+
if not todo:
|
| 337 |
+
raise HTTPException(
|
| 338 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 339 |
+
detail='Todo not found',
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
db.delete(todo)
|
| 343 |
+
db.commit()
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
__all__ = ['router']
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User API routes.
|
| 3 |
+
|
| 4 |
+
Provides endpoints for user profile management.
|
| 5 |
+
"""
|
| 6 |
+
from uuid import UUID
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException, status, Depends, File, UploadFile
|
| 10 |
+
from sqlmodel import Session, select, func
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from src.api.deps import get_current_user_id, get_db
|
| 14 |
+
from src.models.user import User
|
| 15 |
+
from src.models.todo import Todo, Status
|
| 16 |
+
from src.schemas.user import UserResponse, UserProfileUpdateRequest
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get(
|
| 22 |
+
'/me',
|
| 23 |
+
response_model=dict,
|
| 24 |
+
summary='Get current user profile',
|
| 25 |
+
description='Get current user profile with statistics',
|
| 26 |
+
)
|
| 27 |
+
async def get_profile(
|
| 28 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 29 |
+
db: Session = Depends(get_db),
|
| 30 |
+
):
|
| 31 |
+
"""Get current user profile with todo statistics."""
|
| 32 |
+
# Get user
|
| 33 |
+
query = select(User).where(User.id == UUID(current_user_id))
|
| 34 |
+
user = db.exec(query).first()
|
| 35 |
+
|
| 36 |
+
if not user:
|
| 37 |
+
raise HTTPException(
|
| 38 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 39 |
+
detail='User not found',
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Get todo statistics
|
| 43 |
+
total_todos = db.exec(
|
| 44 |
+
select(func.count()).select_from(Todo).where(Todo.user_id == user.id)
|
| 45 |
+
).one()
|
| 46 |
+
pending_todos = db.exec(
|
| 47 |
+
select(func.count()).select_from(Todo).where(
|
| 48 |
+
Todo.user_id == user.id,
|
| 49 |
+
Todo.status == Status.PENDING
|
| 50 |
+
)
|
| 51 |
+
).one()
|
| 52 |
+
completed_todos = db.exec(
|
| 53 |
+
select(func.count()).select_from(Todo).where(
|
| 54 |
+
Todo.user_id == user.id,
|
| 55 |
+
Todo.status == Status.COMPLETED
|
| 56 |
+
)
|
| 57 |
+
).one()
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
'id': str(user.id),
|
| 61 |
+
'name': user.name,
|
| 62 |
+
'email': user.email,
|
| 63 |
+
'avatar_url': user.avatar_url,
|
| 64 |
+
'created_at': user.created_at.isoformat(),
|
| 65 |
+
'updated_at': user.updated_at.isoformat(),
|
| 66 |
+
'stats': {
|
| 67 |
+
'total_todos': total_todos,
|
| 68 |
+
'pending_todos': pending_todos,
|
| 69 |
+
'completed_todos': completed_todos,
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.put(
|
| 75 |
+
'/me',
|
| 76 |
+
response_model=dict,
|
| 77 |
+
summary='Update user profile',
|
| 78 |
+
description='Update current user profile',
|
| 79 |
+
)
|
| 80 |
+
async def update_profile(
|
| 81 |
+
profile_data: UserProfileUpdateRequest,
|
| 82 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 83 |
+
db: Session = Depends(get_db),
|
| 84 |
+
):
|
| 85 |
+
"""Update current user profile."""
|
| 86 |
+
# Get user
|
| 87 |
+
query = select(User).where(User.id == UUID(current_user_id))
|
| 88 |
+
user = db.exec(query).first()
|
| 89 |
+
|
| 90 |
+
if not user:
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 93 |
+
detail='User not found',
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Update fields
|
| 97 |
+
if profile_data.name is not None:
|
| 98 |
+
user.name = profile_data.name
|
| 99 |
+
user.updated_at = datetime.utcnow()
|
| 100 |
+
|
| 101 |
+
db.add(user)
|
| 102 |
+
db.commit()
|
| 103 |
+
db.refresh(user)
|
| 104 |
+
|
| 105 |
+
return {
|
| 106 |
+
'id': str(user.id),
|
| 107 |
+
'name': user.name,
|
| 108 |
+
'email': user.email,
|
| 109 |
+
'avatar_url': user.avatar_url,
|
| 110 |
+
'created_at': user.created_at.isoformat(),
|
| 111 |
+
'updated_at': user.updated_at.isoformat(),
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post(
|
| 116 |
+
'/me/avatar',
|
| 117 |
+
response_model=dict,
|
| 118 |
+
summary='Upload avatar',
|
| 119 |
+
description='Upload user avatar image',
|
| 120 |
+
)
|
| 121 |
+
async def upload_avatar(
|
| 122 |
+
file: UploadFile = File(...),
|
| 123 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 124 |
+
db: Session = Depends(get_db),
|
| 125 |
+
):
|
| 126 |
+
"""Upload user avatar."""
|
| 127 |
+
# Validate file type
|
| 128 |
+
allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
| 129 |
+
if file.content_type not in allowed_types:
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 132 |
+
detail=f'Invalid file type. Allowed: {", ".join(allowed_types)}',
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Validate file size (5MB max)
|
| 136 |
+
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
| 137 |
+
content = await file.read()
|
| 138 |
+
if len(content) > MAX_FILE_SIZE:
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 141 |
+
detail='File too large. Maximum size: 5MB',
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# For now, return a placeholder avatar URL
|
| 145 |
+
# In production, you would upload to Cloudinary here
|
| 146 |
+
from src.core.config import settings
|
| 147 |
+
|
| 148 |
+
if settings.cloudinary_cloud_name:
|
| 149 |
+
# TODO: Implement Cloudinary upload
|
| 150 |
+
avatar_url = f"https://ui-avatars.com/api/?name={file.filename}&background=random"
|
| 151 |
+
else:
|
| 152 |
+
# Use UI Avatars as fallback
|
| 153 |
+
query = select(User).where(User.id == UUID(current_user_id))
|
| 154 |
+
user = db.exec(query).first()
|
| 155 |
+
avatar_url = f"https://ui-avatars.com/api/?name={user.name}&background=random"
|
| 156 |
+
|
| 157 |
+
# Update user avatar
|
| 158 |
+
query = select(User).where(User.id == UUID(current_user_id))
|
| 159 |
+
user = db.exec(query).first()
|
| 160 |
+
|
| 161 |
+
if user:
|
| 162 |
+
user.avatar_url = avatar_url
|
| 163 |
+
user.updated_at = datetime.utcnow()
|
| 164 |
+
db.add(user)
|
| 165 |
+
db.commit()
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
'avatar_url': avatar_url,
|
| 169 |
+
'message': 'Avatar uploaded successfully',
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
__all__ = ['router']
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application configuration using pydantic-settings.
|
| 3 |
+
|
| 4 |
+
Loads environment variables from .env file and provides type-safe access.
|
| 5 |
+
"""
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from pydantic import Field, field_validator
|
| 10 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Settings(BaseSettings):
|
| 14 |
+
"""Application settings loaded from environment variables."""
|
| 15 |
+
|
| 16 |
+
model_config = SettingsConfigDict(
|
| 17 |
+
env_file='.env',
|
| 18 |
+
env_file_encoding='utf-8',
|
| 19 |
+
case_sensitive=False,
|
| 20 |
+
extra='ignore',
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# ========================================
|
| 24 |
+
# Database Configuration
|
| 25 |
+
# ========================================
|
| 26 |
+
database_url: str = Field(
|
| 27 |
+
default='postgresql+psycopg://todoapp:todoapp_password@localhost:5432/todoapp',
|
| 28 |
+
description='PostgreSQL connection string',
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# ========================================
|
| 32 |
+
# JWT Authentication
|
| 33 |
+
# ========================================
|
| 34 |
+
jwt_secret: str = Field(
|
| 35 |
+
...,
|
| 36 |
+
min_length=32,
|
| 37 |
+
description='Secret key for JWT token signing (min 32 characters)',
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
jwt_algorithm: str = Field(default='HS256', description='JWT algorithm')
|
| 41 |
+
jwt_expiration_days: int = Field(default=7, description='JWT token expiration in days')
|
| 42 |
+
|
| 43 |
+
# ========================================
|
| 44 |
+
# Cloudinary Configuration (Avatar Storage)
|
| 45 |
+
# ========================================
|
| 46 |
+
cloudinary_cloud_name: Optional[str] = Field(
|
| 47 |
+
default=None, description='Cloudinary cloud name'
|
| 48 |
+
)
|
| 49 |
+
cloudinary_api_key: Optional[str] = Field(default=None, description='Cloudinary API key')
|
| 50 |
+
cloudinary_api_secret: Optional[str] = Field(
|
| 51 |
+
default=None, description='Cloudinary API secret'
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# ========================================
|
| 55 |
+
# Hugging Face AI Configuration
|
| 56 |
+
# ========================================
|
| 57 |
+
huggingface_api_key: Optional[str] = Field(
|
| 58 |
+
default=None, description='Hugging Face API key'
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# ========================================
|
| 62 |
+
# Frontend URL
|
| 63 |
+
# ========================================
|
| 64 |
+
frontend_url: str = Field(
|
| 65 |
+
default='http://localhost:3000',
|
| 66 |
+
description='Allowed CORS origin for frontend',
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# ========================================
|
| 70 |
+
# Application Settings
|
| 71 |
+
# ========================================
|
| 72 |
+
env: str = Field(default='development', description='Environment: development, staging, production')
|
| 73 |
+
port: int = Field(default=8000, description='API port')
|
| 74 |
+
log_level: str = Field(default='info', description='Log level: debug, info, warning, error, critical')
|
| 75 |
+
|
| 76 |
+
# ========================================
|
| 77 |
+
# Security Settings
|
| 78 |
+
# ========================================
|
| 79 |
+
bcrypt_rounds: int = Field(default=12, description='Bcrypt password hashing rounds')
|
| 80 |
+
cors_origins: list[str] = Field(
|
| 81 |
+
default=['http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002', 'http://127.0.0.1:3000', 'http://127.0.0.1:3001', 'http://127.0.0.1:3002'], description='CORS allowed origins'
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
@field_validator('env')
|
| 85 |
+
@classmethod
|
| 86 |
+
def validate_environment(cls, v: str) -> str:
|
| 87 |
+
"""Validate environment value."""
|
| 88 |
+
allowed = ['development', 'staging', 'production']
|
| 89 |
+
if v not in allowed:
|
| 90 |
+
raise ValueError(f'env must be one of {allowed}')
|
| 91 |
+
return v
|
| 92 |
+
|
| 93 |
+
@field_validator('log_level')
|
| 94 |
+
@classmethod
|
| 95 |
+
def validate_log_level(cls, v: str) -> str:
|
| 96 |
+
"""Validate log level value."""
|
| 97 |
+
allowed = ['debug', 'info', 'warning', 'error', 'critical']
|
| 98 |
+
if v not in allowed:
|
| 99 |
+
raise ValueError(f'log_level must be one of {allowed}')
|
| 100 |
+
return v
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def is_development(self) -> bool:
|
| 104 |
+
"""Check if running in development mode."""
|
| 105 |
+
return self.env == 'development'
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def is_production(self) -> bool:
|
| 109 |
+
"""Check if running in production mode."""
|
| 110 |
+
return self.env == 'production'
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def database_url_sync(self) -> str:
|
| 114 |
+
"""
|
| 115 |
+
Get synchronous database URL for Alembic migrations.
|
| 116 |
+
Replaces postgresql+psycopg with postgresql+psycopg2.
|
| 117 |
+
"""
|
| 118 |
+
return self.database_url.replace('+psycopg', '+psycopg2')
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@lru_cache()
|
| 122 |
+
def get_settings() -> Settings:
|
| 123 |
+
"""
|
| 124 |
+
Get cached settings instance.
|
| 125 |
+
|
| 126 |
+
Uses lru_cache to ensure settings are loaded only once.
|
| 127 |
+
"""
|
| 128 |
+
return Settings()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# Export settings instance
|
| 132 |
+
settings = get_settings()
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database configuration and session management.
|
| 3 |
+
|
| 4 |
+
Provides SQLAlchemy engine with connection pooling and session dependency for FastAPI.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Generator
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import create_engine, text
|
| 9 |
+
from sqlalchemy.exc import SQLAlchemyError
|
| 10 |
+
from sqlmodel import Session, SQLModel
|
| 11 |
+
|
| 12 |
+
from src.core.config import settings
|
| 13 |
+
|
| 14 |
+
# Create SQLAlchemy engine with connection pooling
|
| 15 |
+
engine = create_engine(
|
| 16 |
+
str(settings.database_url),
|
| 17 |
+
pool_size=10, # Number of connections to maintain
|
| 18 |
+
max_overflow=20, # Additional connections when pool is full
|
| 19 |
+
pool_recycle=3600, # Recycle connections after 1 hour
|
| 20 |
+
pool_pre_ping=True, # Verify connections before using
|
| 21 |
+
echo=settings.is_development, # Log SQL in development
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def init_db() -> None:
|
| 26 |
+
"""
|
| 27 |
+
Initialize database by creating all tables.
|
| 28 |
+
|
| 29 |
+
This should only be used for development/testing.
|
| 30 |
+
In production, use Alembic migrations instead.
|
| 31 |
+
"""
|
| 32 |
+
SQLModel.metadata.create_all(engine)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_session() -> Generator[Session, None, None]:
|
| 36 |
+
"""
|
| 37 |
+
FastAPI dependency for database session.
|
| 38 |
+
|
| 39 |
+
Yields a database session and ensures it's closed after use.
|
| 40 |
+
Automatically handles rollback on errors.
|
| 41 |
+
|
| 42 |
+
Yields:
|
| 43 |
+
Session: SQLAlchemy session
|
| 44 |
+
|
| 45 |
+
Example:
|
| 46 |
+
@app.get("/users")
|
| 47 |
+
def get_users(db: Session = Depends(get_session)):
|
| 48 |
+
return db.exec(select(User)).all()
|
| 49 |
+
"""
|
| 50 |
+
session = Session(engine)
|
| 51 |
+
try:
|
| 52 |
+
yield session
|
| 53 |
+
session.commit()
|
| 54 |
+
except SQLAlchemyError:
|
| 55 |
+
session.rollback()
|
| 56 |
+
raise
|
| 57 |
+
finally:
|
| 58 |
+
session.close()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class DatabaseManager:
|
| 62 |
+
"""
|
| 63 |
+
Database manager for advanced operations.
|
| 64 |
+
|
| 65 |
+
Provides methods for health checks, connection testing,
|
| 66 |
+
and administrative tasks.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
@staticmethod
|
| 70 |
+
def check_connection() -> bool:
|
| 71 |
+
"""
|
| 72 |
+
Check if database connection is alive.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
bool: True if connection is successful, False otherwise
|
| 76 |
+
"""
|
| 77 |
+
try:
|
| 78 |
+
with engine.connect() as conn:
|
| 79 |
+
conn.execute(text("SELECT 1"))
|
| 80 |
+
return True
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"Database connection error: {e}")
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def get_pool_status() -> dict:
|
| 87 |
+
"""
|
| 88 |
+
Get connection pool status.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
dict: Pool statistics including size, checked out, and overflow
|
| 92 |
+
"""
|
| 93 |
+
pool = engine.pool
|
| 94 |
+
return {
|
| 95 |
+
'pool_size': pool.size(),
|
| 96 |
+
'checked_out': pool.checkedout(),
|
| 97 |
+
'overflow': pool.overflow(),
|
| 98 |
+
'max_overflow': engine.pool.max_overflow,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Export for use in other modules
|
| 103 |
+
__all__ = ['engine', 'get_session', 'init_db', 'DatabaseManager']
|
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Security utilities for authentication and password management.
|
| 3 |
+
|
| 4 |
+
Provides password hashing with bcrypt and JWT token creation/verification.
|
| 5 |
+
"""
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from passlib.context import CryptContext
|
| 11 |
+
|
| 12 |
+
from src.core.config import settings
|
| 13 |
+
|
| 14 |
+
# Password hashing context with bcrypt
|
| 15 |
+
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 19 |
+
"""
|
| 20 |
+
Verify a plain password against a hashed password.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
plain_password: Plain text password to verify
|
| 24 |
+
hashed_password: Hashed password to compare against
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
bool: True if passwords match, False otherwise
|
| 28 |
+
"""
|
| 29 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_password_hash(password: str) -> str:
|
| 33 |
+
"""
|
| 34 |
+
Hash a password using bcrypt.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
password: Plain text password to hash
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
str: Hashed password
|
| 41 |
+
"""
|
| 42 |
+
return pwd_context.hash(password)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 46 |
+
"""
|
| 47 |
+
Create a JWT access token.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
data: Data to encode in the token (typically {'sub': user_id})
|
| 51 |
+
expires_delta: Optional custom expiration time
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
str: Encoded JWT token
|
| 55 |
+
|
| 56 |
+
Example:
|
| 57 |
+
token = create_access_token(data={'sub': str(user.id)})
|
| 58 |
+
"""
|
| 59 |
+
to_encode = data.copy()
|
| 60 |
+
|
| 61 |
+
# Set expiration time
|
| 62 |
+
if expires_delta:
|
| 63 |
+
expire = datetime.utcnow() + expires_delta
|
| 64 |
+
else:
|
| 65 |
+
expire = datetime.utcnow() + timedelta(days=settings.jwt_expiration_days)
|
| 66 |
+
|
| 67 |
+
to_encode.update({'exp': expire})
|
| 68 |
+
|
| 69 |
+
# Encode token
|
| 70 |
+
encoded_jwt = jwt.encode(
|
| 71 |
+
to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return encoded_jwt
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def decode_access_token(token: str) -> Optional[dict]:
|
| 78 |
+
"""
|
| 79 |
+
Decode and verify a JWT access token.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
token: JWT token to decode
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
dict: Decoded token payload if valid, None if invalid
|
| 86 |
+
|
| 87 |
+
Example:
|
| 88 |
+
payload = decode_access_token(token)
|
| 89 |
+
if payload:
|
| 90 |
+
user_id = payload.get('sub')
|
| 91 |
+
"""
|
| 92 |
+
try:
|
| 93 |
+
payload = jwt.decode(
|
| 94 |
+
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
| 95 |
+
)
|
| 96 |
+
return payload
|
| 97 |
+
except JWTError:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class TokenData:
|
| 102 |
+
"""
|
| 103 |
+
Token data model for decoded JWT tokens.
|
| 104 |
+
|
| 105 |
+
Attributes:
|
| 106 |
+
user_id: User ID from token subject
|
| 107 |
+
exp: Token expiration timestamp
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
def __init__(self, user_id: Optional[str] = None, exp: Optional[int] = None):
|
| 111 |
+
self.user_id = user_id
|
| 112 |
+
self.exp = exp
|
| 113 |
+
|
| 114 |
+
@classmethod
|
| 115 |
+
def from_token(cls, token: str) -> Optional['TokenData']:
|
| 116 |
+
"""
|
| 117 |
+
Create TokenData from JWT token.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
token: JWT token to decode
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
TokenData if token is valid, None otherwise
|
| 124 |
+
"""
|
| 125 |
+
payload = decode_access_token(token)
|
| 126 |
+
if payload is None:
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
user_id = payload.get('sub')
|
| 130 |
+
exp = payload.get('exp')
|
| 131 |
+
|
| 132 |
+
return cls(user_id=user_id, exp=exp)
|
| 133 |
+
|
| 134 |
+
def is_expired(self) -> bool:
|
| 135 |
+
"""
|
| 136 |
+
Check if token is expired.
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
bool: True if token is expired, False otherwise
|
| 140 |
+
"""
|
| 141 |
+
if self.exp is None:
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
return datetime.utcnow().timestamp() > self.exp
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# Export for use in other modules
|
| 148 |
+
__all__ = [
|
| 149 |
+
'verify_password',
|
| 150 |
+
'get_password_hash',
|
| 151 |
+
'create_access_token',
|
| 152 |
+
'decode_access_token',
|
| 153 |
+
'TokenData',
|
| 154 |
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application main entry point.
|
| 3 |
+
|
| 4 |
+
Configures the FastAPI app with CORS middleware, routes, and middleware.
|
| 5 |
+
"""
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from typing import AsyncGenerator
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, Request
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
|
| 13 |
+
from src.core.config import settings
|
| 14 |
+
from src.core.database import DatabaseManager, init_db
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@asynccontextmanager
|
| 18 |
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
| 19 |
+
"""
|
| 20 |
+
Lifespan context manager for FastAPI app.
|
| 21 |
+
|
| 22 |
+
Handles startup and shutdown events.
|
| 23 |
+
"""
|
| 24 |
+
# Startup
|
| 25 |
+
print(f"Starting Todo App API")
|
| 26 |
+
print(f"Environment: {settings.env}")
|
| 27 |
+
print(f"Database: {settings.database_url.split('@')[-1]}")
|
| 28 |
+
|
| 29 |
+
# Initialize database (create tables if not exists)
|
| 30 |
+
# In production, use Alembic migrations instead
|
| 31 |
+
if settings.is_development:
|
| 32 |
+
init_db()
|
| 33 |
+
print("Database initialized")
|
| 34 |
+
|
| 35 |
+
yield
|
| 36 |
+
|
| 37 |
+
# Shutdown
|
| 38 |
+
print("Shutting down Todo App API")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# Create FastAPI app
|
| 42 |
+
app = FastAPI(
|
| 43 |
+
title='Todo App API',
|
| 44 |
+
description='Premium Todo SaaS Application API',
|
| 45 |
+
version='0.1.0',
|
| 46 |
+
docs_url='/docs',
|
| 47 |
+
redoc_url='/redoc',
|
| 48 |
+
lifespan=lifespan,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Configure CORS middleware
|
| 53 |
+
app.add_middleware(
|
| 54 |
+
CORSMiddleware,
|
| 55 |
+
allow_origins=settings.cors_origins,
|
| 56 |
+
allow_credentials=True,
|
| 57 |
+
allow_methods=['*'],
|
| 58 |
+
allow_headers=['*'],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# Global exception handler
|
| 63 |
+
@app.exception_handler(Exception)
|
| 64 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 65 |
+
"""Handle all unhandled exceptions."""
|
| 66 |
+
print(f"Unhandled exception: {exc}")
|
| 67 |
+
return JSONResponse(
|
| 68 |
+
status_code=500,
|
| 69 |
+
content={
|
| 70 |
+
'detail': 'Internal server error',
|
| 71 |
+
'message': str(exc) if settings.is_development else 'An error occurred',
|
| 72 |
+
},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# Health check endpoint
|
| 77 |
+
@app.get('/health', tags=['Health'])
|
| 78 |
+
async def health_check():
|
| 79 |
+
"""
|
| 80 |
+
Health check endpoint.
|
| 81 |
+
|
| 82 |
+
Returns API status and database connection status.
|
| 83 |
+
"""
|
| 84 |
+
db_connected = DatabaseManager.check_connection()
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
'status': 'healthy',
|
| 88 |
+
'api': 'Todo App API',
|
| 89 |
+
'version': '0.1.0',
|
| 90 |
+
'environment': settings.env,
|
| 91 |
+
'database': 'connected' if db_connected else 'disconnected',
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# Root endpoint
|
| 96 |
+
@app.get('/', tags=['Root'])
|
| 97 |
+
async def root():
|
| 98 |
+
"""
|
| 99 |
+
Root endpoint with API information.
|
| 100 |
+
"""
|
| 101 |
+
return {
|
| 102 |
+
'message': 'Welcome to Todo App API',
|
| 103 |
+
'version': '0.1.0',
|
| 104 |
+
'docs': '/docs',
|
| 105 |
+
'health': '/health',
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# Include routers
|
| 110 |
+
from src.api import auth, todos, users, ai
|
| 111 |
+
|
| 112 |
+
app.include_router(auth.router, prefix='/api/auth', tags=['Authentication'])
|
| 113 |
+
app.include_router(todos.router, prefix='/api/todos', tags=['Todos'])
|
| 114 |
+
app.include_router(users.router, prefix='/api/users', tags=['Users'])
|
| 115 |
+
app.include_router(ai.router, prefix='/api/ai', tags=['AI'])
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == '__main__':
|
| 119 |
+
import uvicorn
|
| 120 |
+
|
| 121 |
+
uvicorn.run(
|
| 122 |
+
'src.main:app',
|
| 123 |
+
host='0.0.0.0',
|
| 124 |
+
port=settings.port,
|
| 125 |
+
reload=settings.is_development,
|
| 126 |
+
)
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AIRequest model for tracking AI feature usage.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from uuid import UUID, uuid4
|
| 8 |
+
|
| 9 |
+
from sqlmodel import Column, DateTime, Field, ForeignKey, SQLModel, Text
|
| 10 |
+
from sqlalchemy import text, Index
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AIRequestType(str, Enum):
|
| 14 |
+
"""Types of AI requests."""
|
| 15 |
+
|
| 16 |
+
GENERATE_TODO = 'generate_todo'
|
| 17 |
+
SUMMARIZE = 'summarize'
|
| 18 |
+
PRIORITIZE = 'prioritize'
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AIRequest(SQLModel, table=True):
|
| 22 |
+
"""
|
| 23 |
+
AIRequest model for tracking AI feature usage.
|
| 24 |
+
|
| 25 |
+
Attributes:
|
| 26 |
+
id: Unique request identifier (UUID)
|
| 27 |
+
user_id: User who made the request (foreign key)
|
| 28 |
+
request_type: Type of AI request
|
| 29 |
+
input_data: Input data sent to AI
|
| 30 |
+
output_data: Output data from AI
|
| 31 |
+
model_used: AI model used for processing
|
| 32 |
+
tokens_used: Optional number of tokens used
|
| 33 |
+
processing_time_ms: Processing time in milliseconds
|
| 34 |
+
created_at: Request timestamp
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
__tablename__ = 'ai_requests'
|
| 38 |
+
|
| 39 |
+
id: UUID = Field(
|
| 40 |
+
default_factory=uuid4,
|
| 41 |
+
primary_key=True,
|
| 42 |
+
index=True,
|
| 43 |
+
description='Unique request identifier',
|
| 44 |
+
)
|
| 45 |
+
user_id: UUID = Field(
|
| 46 |
+
default=None,
|
| 47 |
+
foreign_key='users.id',
|
| 48 |
+
nullable=False,
|
| 49 |
+
index=True,
|
| 50 |
+
description='User who made the request',
|
| 51 |
+
)
|
| 52 |
+
request_type: AIRequestType = Field(
|
| 53 |
+
description='Type of AI request',
|
| 54 |
+
)
|
| 55 |
+
input_data: str = Field(
|
| 56 |
+
sa_column=Column(Text),
|
| 57 |
+
description='Input data sent to AI',
|
| 58 |
+
)
|
| 59 |
+
output_data: Optional[str] = Field(
|
| 60 |
+
default=None,
|
| 61 |
+
sa_column=Column(Text),
|
| 62 |
+
description='Output data from AI',
|
| 63 |
+
)
|
| 64 |
+
model_used: str = Field(
|
| 65 |
+
max_length=100,
|
| 66 |
+
description='AI model used',
|
| 67 |
+
)
|
| 68 |
+
tokens_used: Optional[int] = Field(
|
| 69 |
+
default=None,
|
| 70 |
+
description='Number of tokens used',
|
| 71 |
+
)
|
| 72 |
+
processing_time_ms: Optional[int] = Field(
|
| 73 |
+
default=None,
|
| 74 |
+
description='Processing time in milliseconds',
|
| 75 |
+
)
|
| 76 |
+
created_at: datetime = Field(
|
| 77 |
+
default_factory=datetime.utcnow,
|
| 78 |
+
sa_column=Column(DateTime(), server_default=text('CURRENT_TIMESTAMP')),
|
| 79 |
+
description='Request timestamp',
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Define indexes
|
| 83 |
+
__table_args__ = (
|
| 84 |
+
Index('idx_ai_requests_user_type', 'user_id', 'request_type'),
|
| 85 |
+
Index('idx_ai_requests_created', 'created_at'),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def __repr__(self) -> str:
|
| 89 |
+
return f'<AIRequest {self.request_type}>'
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# Export for use in other modules
|
| 93 |
+
__all__ = ['AIRequest', 'AIRequestType']
|
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session model for JWT token management.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from uuid import UUID, uuid4
|
| 7 |
+
|
| 8 |
+
from sqlmodel import Column, DateTime, Field, ForeignKey, SQLModel, Text
|
| 9 |
+
from sqlalchemy import text, Index
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Session(SQLModel, table=True):
|
| 13 |
+
"""
|
| 14 |
+
Session model for tracking active JWT tokens.
|
| 15 |
+
|
| 16 |
+
Attributes:
|
| 17 |
+
id: Unique session identifier (UUID)
|
| 18 |
+
user_id: Associated user ID (foreign key)
|
| 19 |
+
token: JWT token (hashed or partial)
|
| 20 |
+
expires_at: Token expiration timestamp
|
| 21 |
+
created_at: Session creation timestamp
|
| 22 |
+
revoked_at: Optional revocation timestamp
|
| 23 |
+
user_agent: Optional user agent string
|
| 24 |
+
ip_address: Optional IP address
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
__tablename__ = 'sessions'
|
| 28 |
+
|
| 29 |
+
id: UUID = Field(
|
| 30 |
+
default_factory=uuid4,
|
| 31 |
+
primary_key=True,
|
| 32 |
+
index=True,
|
| 33 |
+
description='Unique session identifier',
|
| 34 |
+
)
|
| 35 |
+
user_id: UUID = Field(
|
| 36 |
+
default=None,
|
| 37 |
+
foreign_key='users.id',
|
| 38 |
+
nullable=False,
|
| 39 |
+
index=True,
|
| 40 |
+
description='Associated user ID',
|
| 41 |
+
)
|
| 42 |
+
token: str = Field(
|
| 43 |
+
max_length=500,
|
| 44 |
+
index=True,
|
| 45 |
+
description='JWT token identifier',
|
| 46 |
+
)
|
| 47 |
+
expires_at: datetime = Field(
|
| 48 |
+
description='Token expiration timestamp',
|
| 49 |
+
)
|
| 50 |
+
created_at: datetime = Field(
|
| 51 |
+
default_factory=datetime.utcnow,
|
| 52 |
+
sa_column=Column(DateTime(), server_default=text('CURRENT_TIMESTAMP')),
|
| 53 |
+
description='Session creation timestamp',
|
| 54 |
+
)
|
| 55 |
+
revoked_at: Optional[datetime] = Field(
|
| 56 |
+
default=None,
|
| 57 |
+
description='Revocation timestamp',
|
| 58 |
+
)
|
| 59 |
+
user_agent: Optional[str] = Field(
|
| 60 |
+
default=None,
|
| 61 |
+
max_length=500,
|
| 62 |
+
description='User agent string',
|
| 63 |
+
)
|
| 64 |
+
ip_address: Optional[str] = Field(
|
| 65 |
+
default=None,
|
| 66 |
+
max_length=45,
|
| 67 |
+
description='IP address (IPv4 or IPv6)',
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Define indexes
|
| 71 |
+
__table_args__ = (
|
| 72 |
+
Index('idx_sessions_user_expires', 'user_id', 'expires_at'),
|
| 73 |
+
Index('idx_sessions_token', 'token'),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def __repr__(self) -> str:
|
| 77 |
+
return f'<Session {self.id}>'
|
| 78 |
+
|
| 79 |
+
def is_valid(self) -> bool:
|
| 80 |
+
"""Check if session is valid (not expired and not revoked)."""
|
| 81 |
+
if self.revoked_at is not None:
|
| 82 |
+
return False
|
| 83 |
+
return datetime.utcnow() < self.expires_at
|
| 84 |
+
|
| 85 |
+
def revoke(self) -> None:
|
| 86 |
+
"""Revoke the session."""
|
| 87 |
+
self.revoked_at = datetime.utcnow()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Export for use in other modules
|
| 91 |
+
__all__ = ['Session']
|
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Todo model for task management.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
from uuid import UUID, uuid4
|
| 8 |
+
|
| 9 |
+
from pydantic import Field as PydanticField
|
| 10 |
+
from sqlmodel import Column, DateTime, Field, ForeignKey, SQLModel, Text
|
| 11 |
+
from sqlalchemy import text, Index, ARRAY, String
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Priority(str, Enum):
|
| 15 |
+
"""Todo priority levels."""
|
| 16 |
+
|
| 17 |
+
LOW = 'low'
|
| 18 |
+
MEDIUM = 'medium'
|
| 19 |
+
HIGH = 'high'
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Status(str, Enum):
|
| 23 |
+
"""Todo status values."""
|
| 24 |
+
|
| 25 |
+
PENDING = 'pending'
|
| 26 |
+
COMPLETED = 'completed'
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Todo(SQLModel, table=True):
|
| 30 |
+
"""
|
| 31 |
+
Todo model representing user tasks.
|
| 32 |
+
|
| 33 |
+
Attributes:
|
| 34 |
+
id: Unique todo identifier (UUID)
|
| 35 |
+
title: Todo title
|
| 36 |
+
description: Optional detailed description
|
| 37 |
+
status: Current status (pending, in_progress, completed, cancelled)
|
| 38 |
+
priority: Priority level (low, medium, high)
|
| 39 |
+
due_date: Optional due date
|
| 40 |
+
completed_at: Optional completion timestamp
|
| 41 |
+
user_id: Owner user ID (foreign key)
|
| 42 |
+
created_at: Creation timestamp
|
| 43 |
+
updated_at: Last update timestamp
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
__tablename__ = 'todos'
|
| 47 |
+
|
| 48 |
+
id: UUID = Field(
|
| 49 |
+
default_factory=uuid4,
|
| 50 |
+
primary_key=True,
|
| 51 |
+
index=True,
|
| 52 |
+
description='Unique todo identifier',
|
| 53 |
+
)
|
| 54 |
+
title: str = Field(max_length=255, description='Todo title')
|
| 55 |
+
description: Optional[str] = Field(
|
| 56 |
+
default=None, sa_column=Column(Text), description='Detailed description'
|
| 57 |
+
)
|
| 58 |
+
status: Status = Field(
|
| 59 |
+
default=Status.PENDING,
|
| 60 |
+
description='Current status',
|
| 61 |
+
)
|
| 62 |
+
priority: Priority = Field(
|
| 63 |
+
default=Priority.MEDIUM,
|
| 64 |
+
description='Priority level',
|
| 65 |
+
)
|
| 66 |
+
due_date: Optional[datetime] = Field(
|
| 67 |
+
default=None,
|
| 68 |
+
description='Due date',
|
| 69 |
+
)
|
| 70 |
+
tags: Optional[List[str]] = Field(
|
| 71 |
+
default=None,
|
| 72 |
+
sa_column=Column(ARRAY(String)), # PostgreSQL array type
|
| 73 |
+
description='Tags for categorization',
|
| 74 |
+
)
|
| 75 |
+
completed_at: Optional[datetime] = Field(
|
| 76 |
+
default=None,
|
| 77 |
+
description='Completion timestamp',
|
| 78 |
+
)
|
| 79 |
+
user_id: UUID = Field(
|
| 80 |
+
default=None,
|
| 81 |
+
foreign_key='users.id',
|
| 82 |
+
nullable=False,
|
| 83 |
+
index=True,
|
| 84 |
+
description='Owner user ID',
|
| 85 |
+
)
|
| 86 |
+
created_at: datetime = Field(
|
| 87 |
+
default_factory=datetime.utcnow,
|
| 88 |
+
sa_column=Column(DateTime(), server_default=text('CURRENT_TIMESTAMP')),
|
| 89 |
+
description='Creation timestamp',
|
| 90 |
+
)
|
| 91 |
+
updated_at: datetime = Field(
|
| 92 |
+
default_factory=datetime.utcnow,
|
| 93 |
+
sa_column=Column(
|
| 94 |
+
DateTime(),
|
| 95 |
+
server_default=text('CURRENT_TIMESTAMP'),
|
| 96 |
+
onupdate=text('CURRENT_TIMESTAMP'),
|
| 97 |
+
),
|
| 98 |
+
description='Last update timestamp',
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Define indexes
|
| 102 |
+
__table_args__ = (
|
| 103 |
+
Index('idx_todos_user_status', 'user_id', 'status'),
|
| 104 |
+
Index('idx_todos_user_priority', 'user_id', 'priority'),
|
| 105 |
+
Index('idx_todos_due_date', 'due_date'),
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def __repr__(self) -> str:
|
| 109 |
+
return f'<Todo {self.title}>'
|
| 110 |
+
|
| 111 |
+
def mark_completed(self) -> None:
|
| 112 |
+
"""Mark todo as completed."""
|
| 113 |
+
self.status = Status.COMPLETED
|
| 114 |
+
self.completed_at = datetime.utcnow()
|
| 115 |
+
|
| 116 |
+
def is_overdue(self) -> bool:
|
| 117 |
+
"""Check if todo is overdue."""
|
| 118 |
+
if self.due_date is None or self.status == Status.COMPLETED:
|
| 119 |
+
return False
|
| 120 |
+
return datetime.utcnow() > self.due_date
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# Export for use in other modules
|
| 124 |
+
__all__ = ['Todo', 'Priority', 'Status']
|
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User model for authentication and profile.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from uuid import UUID, uuid4
|
| 7 |
+
|
| 8 |
+
from sqlmodel import Column, DateTime, Field, SQLModel
|
| 9 |
+
from sqlalchemy import text
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class User(SQLModel, table=True):
|
| 13 |
+
"""
|
| 14 |
+
User model representing application users.
|
| 15 |
+
|
| 16 |
+
Attributes:
|
| 17 |
+
id: Unique user identifier (UUID)
|
| 18 |
+
name: User's full name
|
| 19 |
+
email: User's email address (unique)
|
| 20 |
+
password_hash: Bcrypt hashed password
|
| 21 |
+
avatar_url: Optional Cloudinary avatar URL
|
| 22 |
+
created_at: Account creation timestamp
|
| 23 |
+
updated_at: Last update timestamp
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
__tablename__ = 'users'
|
| 27 |
+
|
| 28 |
+
id: UUID = Field(
|
| 29 |
+
default_factory=uuid4,
|
| 30 |
+
primary_key=True,
|
| 31 |
+
index=True,
|
| 32 |
+
description='Unique user identifier',
|
| 33 |
+
)
|
| 34 |
+
name: str = Field(max_length=255, description="User's full name")
|
| 35 |
+
email: str = Field(
|
| 36 |
+
unique=True,
|
| 37 |
+
index=True,
|
| 38 |
+
max_length=255,
|
| 39 |
+
description="User's email address",
|
| 40 |
+
)
|
| 41 |
+
password_hash: str = Field(max_length=255, description='Bcrypt hashed password', exclude=True)
|
| 42 |
+
avatar_url: Optional[str] = Field(
|
| 43 |
+
default=None, max_length=500, description='Cloudinary avatar URL'
|
| 44 |
+
)
|
| 45 |
+
created_at: datetime = Field(
|
| 46 |
+
default_factory=datetime.utcnow,
|
| 47 |
+
sa_column=Column(DateTime(), server_default=text('CURRENT_TIMESTAMP')),
|
| 48 |
+
description='Account creation timestamp',
|
| 49 |
+
)
|
| 50 |
+
updated_at: datetime = Field(
|
| 51 |
+
default_factory=datetime.utcnow,
|
| 52 |
+
sa_column=Column(
|
| 53 |
+
DateTime(),
|
| 54 |
+
server_default=text('CURRENT_TIMESTAMP'),
|
| 55 |
+
onupdate=text('CURRENT_TIMESTAMP'),
|
| 56 |
+
),
|
| 57 |
+
description='Last update timestamp',
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def __repr__(self) -> str:
|
| 61 |
+
return f'<User {self.email}>'
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Export for use in other modules
|
| 65 |
+
__all__ = ['User']
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for authentication operations.
|
| 3 |
+
|
| 4 |
+
Used for request/response validation in auth endpoints.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SignupRequest(BaseModel):
|
| 12 |
+
"""Schema for user registration request."""
|
| 13 |
+
|
| 14 |
+
name: str = Field(
|
| 15 |
+
...,
|
| 16 |
+
min_length=1,
|
| 17 |
+
max_length=255,
|
| 18 |
+
description="User's full name",
|
| 19 |
+
)
|
| 20 |
+
email: EmailStr = Field(
|
| 21 |
+
...,
|
| 22 |
+
description="User's email address",
|
| 23 |
+
)
|
| 24 |
+
password: str = Field(
|
| 25 |
+
...,
|
| 26 |
+
min_length=8,
|
| 27 |
+
max_length=128,
|
| 28 |
+
description="Password (min 8 characters, must include letter and number)",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LoginRequest(BaseModel):
|
| 33 |
+
"""Schema for user login request."""
|
| 34 |
+
|
| 35 |
+
email: EmailStr = Field(..., description="User's email address")
|
| 36 |
+
password: str = Field(..., description="User's password")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class AuthResponse(BaseModel):
|
| 40 |
+
"""Schema for authentication response."""
|
| 41 |
+
|
| 42 |
+
access_token: str = Field(..., description="JWT access token")
|
| 43 |
+
token_type: str = Field(default='bearer', description="Token type")
|
| 44 |
+
user: dict = Field(..., description="User information")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class LogoutResponse(BaseModel):
|
| 48 |
+
"""Schema for logout response."""
|
| 49 |
+
|
| 50 |
+
message: str = Field(default='Successfully logged out', description="Logout message")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ErrorResponse(BaseModel):
|
| 54 |
+
"""Schema for error responses."""
|
| 55 |
+
|
| 56 |
+
detail: str = Field(..., description="Error message")
|
| 57 |
+
error_code: Optional[str] = Field(None, description="Error code for client handling")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Export schemas
|
| 61 |
+
__all__ = ['SignupRequest', 'LoginRequest', 'AuthResponse', 'LogoutResponse', 'ErrorResponse']
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Todo schemas for request/response validation.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from uuid import UUID
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TodoCreateRequest(BaseModel):
|
| 11 |
+
"""Request schema for creating a todo."""
|
| 12 |
+
|
| 13 |
+
title: str = Field(..., min_length=1, max_length=500, description="Todo title")
|
| 14 |
+
description: Optional[str] = Field(None, max_length=5000, description="Detailed description")
|
| 15 |
+
priority: Optional[str] = Field("medium", pattern="^(low|medium|high)$", description="Priority level")
|
| 16 |
+
due_date: Optional[datetime] = Field(None, description="Due date")
|
| 17 |
+
tags: Optional[List[str]] = Field(None, description="Tags for categorization")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TodoUpdateRequest(BaseModel):
|
| 21 |
+
"""Request schema for updating a todo."""
|
| 22 |
+
|
| 23 |
+
title: Optional[str] = Field(None, min_length=1, max_length=500, description="Todo title")
|
| 24 |
+
description: Optional[str] = Field(None, max_length=5000, description="Detailed description")
|
| 25 |
+
priority: Optional[str] = Field(None, pattern="^(low|medium|high)$", description="Priority level")
|
| 26 |
+
due_date: Optional[datetime] = Field(None, description="Due date")
|
| 27 |
+
tags: Optional[List[str]] = Field(None, description="Tags for categorization")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class TodoResponse(BaseModel):
|
| 31 |
+
"""Response schema for a todo."""
|
| 32 |
+
|
| 33 |
+
id: str
|
| 34 |
+
user_id: str
|
| 35 |
+
title: str
|
| 36 |
+
description: Optional[str]
|
| 37 |
+
status: str
|
| 38 |
+
priority: str
|
| 39 |
+
tags: Optional[List[str]]
|
| 40 |
+
due_date: Optional[str]
|
| 41 |
+
created_at: str
|
| 42 |
+
updated_at: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TodoListResponse(BaseModel):
|
| 46 |
+
"""Response schema for todo list with pagination."""
|
| 47 |
+
|
| 48 |
+
todos: List[TodoResponse]
|
| 49 |
+
total: int
|
| 50 |
+
skip: int
|
| 51 |
+
limit: int
|
| 52 |
+
has_more: bool
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
__all__ = [
|
| 56 |
+
"TodoCreateRequest",
|
| 57 |
+
"TodoUpdateRequest",
|
| 58 |
+
"TodoResponse",
|
| 59 |
+
"TodoListResponse",
|
| 60 |
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for User model.
|
| 3 |
+
|
| 4 |
+
Used for request/response validation and serialization.
|
| 5 |
+
"""
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from uuid import UUID
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class UserBase(BaseModel):
|
| 14 |
+
"""Base user schema with common fields."""
|
| 15 |
+
|
| 16 |
+
name: str = Field(..., min_length=1, max_length=255, description="User's full name")
|
| 17 |
+
email: EmailStr = Field(..., description="User's email address")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class UserCreate(UserBase):
|
| 21 |
+
"""Schema for creating a new user."""
|
| 22 |
+
|
| 23 |
+
password: str = Field(
|
| 24 |
+
...,
|
| 25 |
+
min_length=8,
|
| 26 |
+
max_length=128,
|
| 27 |
+
description="User's password (min 8 characters)",
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class UserLogin(BaseModel):
|
| 32 |
+
"""Schema for user login."""
|
| 33 |
+
|
| 34 |
+
email: EmailStr = Field(..., description="User's email address")
|
| 35 |
+
password: str = Field(..., description="User's password")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class UserUpdate(BaseModel):
|
| 39 |
+
"""Schema for updating user profile."""
|
| 40 |
+
|
| 41 |
+
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
| 42 |
+
avatar_url: Optional[str] = Field(None, max_length=500)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class UserProfileUpdateRequest(BaseModel):
|
| 46 |
+
"""Schema for updating user profile (minimal)."""
|
| 47 |
+
|
| 48 |
+
name: Optional[str] = Field(None, min_length=1, max_length=255, description="User's full name")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class UserResponse(UserBase):
|
| 52 |
+
"""Schema for user response (excluding sensitive data)."""
|
| 53 |
+
|
| 54 |
+
id: UUID = Field(..., description="User ID")
|
| 55 |
+
avatar_url: Optional[str] = Field(None, description="Avatar URL")
|
| 56 |
+
created_at: datetime = Field(..., description="Account creation timestamp")
|
| 57 |
+
updated_at: datetime = Field(..., description="Last update timestamp")
|
| 58 |
+
|
| 59 |
+
class Config:
|
| 60 |
+
"""Pydantic config."""
|
| 61 |
+
|
| 62 |
+
from_attributes = True # Enable ORM mode
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Export schemas
|
| 66 |
+
__all__ = ['UserBase', 'UserCreate', 'UserLogin', 'UserUpdate', 'UserResponse', 'UserProfileUpdateRequest']
|
|
File without changes
|
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Service for Hugging Face integration.
|
| 3 |
+
|
| 4 |
+
Provides todo generation, summarization, and prioritization features.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from huggingface_hub import InferenceClient
|
| 10 |
+
|
| 11 |
+
from src.core.config import settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AIService:
|
| 15 |
+
"""Service for AI-powered todo features."""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
"""Initialize AI service with Hugging Face client."""
|
| 19 |
+
self.client = None
|
| 20 |
+
if settings.huggingface_api_key:
|
| 21 |
+
self.client = InferenceClient(token=settings.huggingface_api_key)
|
| 22 |
+
|
| 23 |
+
def _generate_todos_prompt(self, goal: str) -> str:
|
| 24 |
+
"""Generate prompt for todo creation."""
|
| 25 |
+
return f"""You are a task planning assistant. Generate 5-7 actionable, specific todo items for this goal: "{goal}"
|
| 26 |
+
|
| 27 |
+
Requirements:
|
| 28 |
+
- Each todo must be specific and actionable
|
| 29 |
+
- Include realistic due dates (relative: "tomorrow", "next week", "next month")
|
| 30 |
+
- Assign priority (low/medium/high)
|
| 31 |
+
- Return as JSON array with exact format below
|
| 32 |
+
|
| 33 |
+
Output format (JSON array):
|
| 34 |
+
{{
|
| 35 |
+
"todos": [
|
| 36 |
+
{{"title": "Research competitors", "description": "Analyze top 3 competitor features", "priority": "high", "due_date": "2025-01-25"}},
|
| 37 |
+
{{"title": "Create wireframes", "description": "Sketch main dashboard screens", "priority": "medium", "due_date": "2025-01-26"}}
|
| 38 |
+
]
|
| 39 |
+
}}
|
| 40 |
+
|
| 41 |
+
Only return JSON, no other text."""
|
| 42 |
+
|
| 43 |
+
def _summarize_todos_prompt(self, todos: List) -> str:
|
| 44 |
+
"""Generate prompt for todo summarization."""
|
| 45 |
+
todos_text = "\n".join([f"- {t['title']}: {t.get('description', '')}" for t in todos])
|
| 46 |
+
return f"""Summarize these {len(todos)} todo items into a concise overview:
|
| 47 |
+
|
| 48 |
+
{todos_text}
|
| 49 |
+
|
| 50 |
+
Provide:
|
| 51 |
+
- Total count breakdown by priority (high/medium/low)
|
| 52 |
+
- Top 3 most urgent items
|
| 53 |
+
- One sentence overall status
|
| 54 |
+
|
| 55 |
+
Keep under 100 words. Be concise and actionable."""
|
| 56 |
+
|
| 57 |
+
def _prioritize_todos_prompt(self, todos: List) -> str:
|
| 58 |
+
"""Generate prompt for todo prioritization."""
|
| 59 |
+
todos_text = "\n".join([
|
| 60 |
+
f"{i+1}. {t['title']} (Priority: {t.get('priority', 'medium')}, Due: {t.get('due_date', 'none')})"
|
| 61 |
+
for i, t in enumerate(todos)
|
| 62 |
+
])
|
| 63 |
+
return f"""You are a productivity expert. Reorder these todos by urgency and importance:
|
| 64 |
+
|
| 65 |
+
Current todos:
|
| 66 |
+
{todos_text}
|
| 67 |
+
|
| 68 |
+
Consider:
|
| 69 |
+
- Due dates (earlier = more urgent)
|
| 70 |
+
- Priority levels explicitly assigned
|
| 71 |
+
- Task dependencies
|
| 72 |
+
|
| 73 |
+
Return as ordered JSON array:
|
| 74 |
+
{{
|
| 75 |
+
"todos": [
|
| 76 |
+
{{"id": "1", "title": "...", "priority_score": 95, "reasoning": "Due tomorrow"}},
|
| 77 |
+
{{"id": "2", "title": "...", "priority_score": 80, "reasoning": "High priority, due in 3 days"}}
|
| 78 |
+
]
|
| 79 |
+
}}
|
| 80 |
+
|
| 81 |
+
Only return JSON, no other text."""
|
| 82 |
+
|
| 83 |
+
def generate_todos(self, goal: str) -> dict:
|
| 84 |
+
"""
|
| 85 |
+
Generate todos from a goal using AI.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
goal: User's goal to break down into todos
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
Dict with generated todos
|
| 92 |
+
|
| 93 |
+
Raises:
|
| 94 |
+
ValueError: If AI service is not configured or response is invalid
|
| 95 |
+
"""
|
| 96 |
+
if not self.client:
|
| 97 |
+
raise ValueError("AI service not configured. Please set HUGGINGFACE_API_KEY.")
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
prompt = self._generate_todos_prompt(goal)
|
| 101 |
+
response = self.client.text_generation(
|
| 102 |
+
prompt,
|
| 103 |
+
model="mistralai/Mistral-7B-Instruct-v0.2",
|
| 104 |
+
max_new_tokens=500,
|
| 105 |
+
temperature=0.7,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Parse JSON response
|
| 109 |
+
response_text = response.strip()
|
| 110 |
+
if "```json" in response_text:
|
| 111 |
+
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 112 |
+
elif "```" in response_text:
|
| 113 |
+
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 114 |
+
|
| 115 |
+
result = json.loads(response_text)
|
| 116 |
+
|
| 117 |
+
return {
|
| 118 |
+
"todos": result.get("todos", []),
|
| 119 |
+
"message": f"Generated {len(result.get('todos', []))} todos for your goal"
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
except json.JSONDecodeError as e:
|
| 123 |
+
raise ValueError(f"Invalid AI response format. Please try again.") from e
|
| 124 |
+
except Exception as e:
|
| 125 |
+
raise ValueError(f"AI service error: {str(e)}") from e
|
| 126 |
+
|
| 127 |
+
def summarize_todos(self, todos: List[dict]) -> dict:
|
| 128 |
+
"""
|
| 129 |
+
Summarize todos using AI.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
todos: List of todo dictionaries
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Dict with summary and breakdown
|
| 136 |
+
"""
|
| 137 |
+
if not self.client:
|
| 138 |
+
raise ValueError("AI service not configured. Please set HUGGINGFACE_API_KEY.")
|
| 139 |
+
|
| 140 |
+
if not todos:
|
| 141 |
+
return {
|
| 142 |
+
"summary": "No todos to summarize.",
|
| 143 |
+
"breakdown": {"high_priority": 0, "medium_priority": 0, "low_priority": 0},
|
| 144 |
+
"urgent_todos": []
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
# Calculate breakdown
|
| 149 |
+
breakdown = {
|
| 150 |
+
"high_priority": sum(1 for t in todos if t.get("priority") == "high"),
|
| 151 |
+
"medium_priority": sum(1 for t in todos if t.get("priority") == "medium"),
|
| 152 |
+
"low_priority": sum(1 for t in todos if t.get("priority") == "low"),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
# Get urgent todos (high priority or due soon)
|
| 156 |
+
from datetime import datetime, timedelta
|
| 157 |
+
urgent = []
|
| 158 |
+
for t in todos:
|
| 159 |
+
if t.get("priority") == "high":
|
| 160 |
+
urgent.append(t.get("title", ""))
|
| 161 |
+
elif t.get("due_date"):
|
| 162 |
+
try:
|
| 163 |
+
due_date = datetime.fromisoformat(t["due_date"].replace("Z", "+00:00"))
|
| 164 |
+
if due_date <= datetime.now() + timedelta(days=2):
|
| 165 |
+
urgent.append(t.get("title", ""))
|
| 166 |
+
except:
|
| 167 |
+
pass
|
| 168 |
+
|
| 169 |
+
# Generate summary
|
| 170 |
+
prompt = self._summarize_todos_prompt(todos)
|
| 171 |
+
summary = self.client.text_generation(
|
| 172 |
+
prompt,
|
| 173 |
+
model="facebook/bart-large-cnn",
|
| 174 |
+
max_new_tokens=200,
|
| 175 |
+
temperature=0.5,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"summary": summary.strip(),
|
| 180 |
+
"breakdown": breakdown,
|
| 181 |
+
"urgent_todos": urgent[:3] # Top 3 urgent
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
raise ValueError(f"AI service error: {str(e)}") from e
|
| 186 |
+
|
| 187 |
+
def prioritize_todos(self, todos: List[dict]) -> dict:
|
| 188 |
+
"""
|
| 189 |
+
Prioritize todos using AI.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
todos: List of todo dictionaries
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
Dict with prioritized todos
|
| 196 |
+
"""
|
| 197 |
+
if not self.client:
|
| 198 |
+
raise ValueError("AI service not configured. Please set HUGGINGFACE_API_KEY.")
|
| 199 |
+
|
| 200 |
+
if not todos:
|
| 201 |
+
return {
|
| 202 |
+
"prioritized_todos": [],
|
| 203 |
+
"message": "No todos to prioritize"
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
prompt = self._prioritize_todos_prompt(todos)
|
| 208 |
+
response = self.client.text_generation(
|
| 209 |
+
prompt,
|
| 210 |
+
model="mistralai/Mistral-7B-Instruct-v0.2",
|
| 211 |
+
max_new_tokens=500,
|
| 212 |
+
temperature=0.7,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Parse JSON response
|
| 216 |
+
response_text = response.strip()
|
| 217 |
+
if "```json" in response_text:
|
| 218 |
+
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
| 219 |
+
elif "```" in response_text:
|
| 220 |
+
response_text = response_text.split("```")[1].split("```")[0].strip()
|
| 221 |
+
|
| 222 |
+
result = json.loads(response_text)
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
"prioritized_todos": result.get("todos", []),
|
| 226 |
+
"message": f"Prioritized {len(result.get('todos', []))} todos"
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
except json.JSONDecodeError as e:
|
| 230 |
+
raise ValueError(f"Invalid AI response format. Please try again.") from e
|
| 231 |
+
except Exception as e:
|
| 232 |
+
raise ValueError(f"AI service error: {str(e)}") from e
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# Global AI service instance
|
| 236 |
+
ai_service = AIService()
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
__all__ = ['ai_service', 'AIService']
|
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication service for user management.
|
| 3 |
+
|
| 4 |
+
Provides functions for user creation, authentication, and JWT token management.
|
| 5 |
+
"""
|
| 6 |
+
import re
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from uuid import UUID
|
| 9 |
+
|
| 10 |
+
from jose import JWTError
|
| 11 |
+
from sqlmodel import Session, select
|
| 12 |
+
|
| 13 |
+
from src.core.config import settings
|
| 14 |
+
from src.core.security import create_access_token, verify_password
|
| 15 |
+
from src.models.user import User
|
| 16 |
+
from src.schemas.user import UserCreate
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def hash_password(password: str) -> str:
|
| 20 |
+
"""
|
| 21 |
+
Hash a password using bcrypt.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
password: Plain text password
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
str: Hashed password
|
| 28 |
+
|
| 29 |
+
Raises:
|
| 30 |
+
ValueError: If password doesn't meet requirements
|
| 31 |
+
"""
|
| 32 |
+
if not password or len(password) < 8:
|
| 33 |
+
raise ValueError('Password must be at least 8 characters long')
|
| 34 |
+
|
| 35 |
+
if not re.search(r'[A-Za-z]', password):
|
| 36 |
+
raise ValueError('Password must contain at least one letter')
|
| 37 |
+
|
| 38 |
+
if not re.search(r'\d', password):
|
| 39 |
+
raise ValueError('Password must contain at least one number')
|
| 40 |
+
|
| 41 |
+
from src.core.security import get_password_hash
|
| 42 |
+
|
| 43 |
+
return get_password_hash(password)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def check_email_exists(db: Session, email: str) -> bool:
|
| 47 |
+
"""
|
| 48 |
+
Check if an email already exists in the database.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
db: Database session
|
| 52 |
+
email: Email to check
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
bool: True if email exists, False otherwise
|
| 56 |
+
"""
|
| 57 |
+
user = db.exec(select(User).where(User.email.ilike(email))).first()
|
| 58 |
+
return user is not None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def create_user(db: Session, user_data: UserCreate) -> User:
|
| 62 |
+
"""
|
| 63 |
+
Create a new user in the database.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
db: Database session
|
| 67 |
+
user_data: User creation data
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
User: Created user object
|
| 71 |
+
|
| 72 |
+
Raises:
|
| 73 |
+
ValueError: If email already exists or password is invalid
|
| 74 |
+
"""
|
| 75 |
+
# Check if email already exists (case-insensitive)
|
| 76 |
+
if check_email_exists(db, user_data.email):
|
| 77 |
+
raise ValueError(f'Email {user_data.email} is already registered')
|
| 78 |
+
|
| 79 |
+
# Hash password
|
| 80 |
+
try:
|
| 81 |
+
password_hash = hash_password(user_data.password)
|
| 82 |
+
except ValueError as e:
|
| 83 |
+
raise ValueError(str(e))
|
| 84 |
+
|
| 85 |
+
# Create new user
|
| 86 |
+
user = User(
|
| 87 |
+
name=user_data.name.strip(),
|
| 88 |
+
email=user_data.email.lower().strip(),
|
| 89 |
+
password_hash=password_hash,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Save to database
|
| 93 |
+
db.add(user)
|
| 94 |
+
db.commit()
|
| 95 |
+
db.refresh(user)
|
| 96 |
+
|
| 97 |
+
return user
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
| 101 |
+
"""
|
| 102 |
+
Authenticate a user with email and password.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
db: Database session
|
| 106 |
+
email: User's email
|
| 107 |
+
password: Plain text password
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
User: User object if authentication successful, None otherwise
|
| 111 |
+
"""
|
| 112 |
+
# Find user by email (case-insensitive)
|
| 113 |
+
user = db.exec(select(User).where(User.email.ilike(email))).first()
|
| 114 |
+
|
| 115 |
+
if not user:
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
# Verify password
|
| 119 |
+
if not verify_password(password, user.password_hash):
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
return user
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def create_user_token(user_id: UUID) -> str:
|
| 126 |
+
"""
|
| 127 |
+
Create a JWT access token for a user.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
user_id: User's UUID
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
str: JWT access token
|
| 134 |
+
|
| 135 |
+
Raises:
|
| 136 |
+
ValueError: If token creation fails
|
| 137 |
+
"""
|
| 138 |
+
try:
|
| 139 |
+
token = create_access_token(data={'sub': str(user_id)})
|
| 140 |
+
return token
|
| 141 |
+
except JWTError as e:
|
| 142 |
+
raise ValueError(f'Failed to create access token: {str(e)}')
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def verify_user_token(token: str) -> Optional[UUID]:
|
| 146 |
+
"""
|
| 147 |
+
Verify a JWT token and extract user ID.
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
token: JWT access token
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
UUID: User ID if token is valid, None otherwise
|
| 154 |
+
"""
|
| 155 |
+
from src.core.security import decode_access_token, TokenData
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
token_data = TokenData.from_token(token)
|
| 159 |
+
if token_data and token_data.user_id and not token_data.is_expired():
|
| 160 |
+
return UUID(token_data.user_id)
|
| 161 |
+
except (JWTError, ValueError):
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
return None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def get_user_by_id(db: Session, user_id: UUID) -> Optional[User]:
|
| 168 |
+
"""
|
| 169 |
+
Get a user by ID.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
db: Database session
|
| 173 |
+
user_id: User's UUID
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
User: User object if found, None otherwise
|
| 177 |
+
"""
|
| 178 |
+
return db.get(User, user_id)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
| 182 |
+
"""
|
| 183 |
+
Get a user by email.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
db: Database session
|
| 187 |
+
email: User's email
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
User: User object if found, None otherwise
|
| 191 |
+
"""
|
| 192 |
+
return db.exec(select(User).where(User.email.ilike(email))).first()
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# Export for use in other modules
|
| 196 |
+
__all__ = [
|
| 197 |
+
'hash_password',
|
| 198 |
+
'check_email_exists',
|
| 199 |
+
'create_user',
|
| 200 |
+
'authenticate_user',
|
| 201 |
+
'create_user_token',
|
| 202 |
+
'verify_user_token',
|
| 203 |
+
'get_user_by_id',
|
| 204 |
+
'get_user_by_email',
|
| 205 |
+
]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Netscape HTTP Cookie File
|
| 2 |
+
# https://curl.se/docs/http-cookies.html
|
| 3 |
+
# This file was generated by libcurl! Edit at your own risk.
|
| 4 |
+
|
| 5 |
+
#HttpOnly_localhost FALSE / FALSE 1769932286 access_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5NjQ3MDViNC1jYzNiLTRmYTktYWY3Yy00OGVkNTdkZGNlNDQiLCJleHAiOjE3Njk5MzIyODZ9.CWtt77dZvCXg9P8SQHI-pQ3nzemeFu8rZuaAmIv6H9c
|
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example docker-compose override for additional development tools
|
| 2 |
+
# Copy this file to docker-compose.override.yml and customize as needed
|
| 3 |
+
|
| 4 |
+
version: '3.8'
|
| 5 |
+
|
| 6 |
+
services:
|
| 7 |
+
# pgAdmin for PostgreSQL Management
|
| 8 |
+
pgadmin:
|
| 9 |
+
image: dpage/pgadmin4:latest
|
| 10 |
+
container_name: todo-app-pgadmin
|
| 11 |
+
restart: unless-stopped
|
| 12 |
+
environment:
|
| 13 |
+
PGADMIN_DEFAULT_EMAIL: admin@todoapp.local
|
| 14 |
+
PGADMIN_DEFAULT_PASSWORD: admin
|
| 15 |
+
ports:
|
| 16 |
+
- '5050:80'
|
| 17 |
+
depends_on:
|
| 18 |
+
- postgres
|
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# PostgreSQL Database for Local Development
|
| 5 |
+
postgres:
|
| 6 |
+
image: postgres:16-alpine
|
| 7 |
+
container_name: todo-app-postgres
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
environment:
|
| 10 |
+
POSTGRES_USER: todoapp
|
| 11 |
+
POSTGRES_PASSWORD: todoapp_password
|
| 12 |
+
POSTGRES_DB: todoapp
|
| 13 |
+
ports:
|
| 14 |
+
- '5432:5432'
|
| 15 |
+
volumes:
|
| 16 |
+
- postgres_data:/var/lib/postgresql/data
|
| 17 |
+
healthcheck:
|
| 18 |
+
test: ['CMD-SHELL', 'pg_isready -U todoapp']
|
| 19 |
+
interval: 10s
|
| 20 |
+
timeout: 5s
|
| 21 |
+
retries: 5
|
| 22 |
+
|
| 23 |
+
# Redis for Caching (Optional - for future use)
|
| 24 |
+
redis:
|
| 25 |
+
image: redis:7-alpine
|
| 26 |
+
container_name: todo-app-redis
|
| 27 |
+
restart: unless-stopped
|
| 28 |
+
ports:
|
| 29 |
+
- '6379:6379'
|
| 30 |
+
volumes:
|
| 31 |
+
- redis_data:/data
|
| 32 |
+
healthcheck:
|
| 33 |
+
test: ['CMD', 'redis-cli', 'ping']
|
| 34 |
+
interval: 10s
|
| 35 |
+
timeout: 3s
|
| 36 |
+
retries: 5
|
| 37 |
+
|
| 38 |
+
volumes:
|
| 39 |
+
postgres_data:
|
| 40 |
+
driver: local
|
| 41 |
+
redis_data:
|
| 42 |
+
driver: local
|
| 43 |
+
|
| 44 |
+
networks:
|
| 45 |
+
default:
|
| 46 |
+
name: todo-app-network
|
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ========================================
|
| 2 |
+
# API Configuration
|
| 3 |
+
# ========================================
|
| 4 |
+
# Backend API URL (change for production)
|
| 5 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 6 |
+
|
| 7 |
+
# ========================================
|
| 8 |
+
# Application Settings
|
| 9 |
+
# ========================================
|
| 10 |
+
# Environment: development, staging, production
|
| 11 |
+
NEXT_PUBLIC_APP_ENV=development
|
| 12 |
+
|
| 13 |
+
# ========================================
|
| 14 |
+
# Feature Flags
|
| 15 |
+
# ========================================
|
| 16 |
+
# Enable AI features (requires HUGGINGFACE_API_KEY in backend)
|
| 17 |
+
NEXT_PUBLIC_ENABLE_AI=true
|
| 18 |
+
|
| 19 |
+
# Enable analytics
|
| 20 |
+
NEXT_PUBLIC_ENABLE_ANALYTICS=false
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": [
|
| 3 |
+
"next/core-web-vitals",
|
| 4 |
+
"prettier"
|
| 5 |
+
],
|
| 6 |
+
"rules": {
|
| 7 |
+
"react/no-unescaped-entities": "off",
|
| 8 |
+
"@next/next/no-page-custom-font": "off",
|
| 9 |
+
"prefer-const": "error",
|
| 10 |
+
"no-unused-vars": "off",
|
| 11 |
+
"@typescript-eslint/no-unused-vars": "off"
|
| 12 |
+
}
|
| 13 |
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"semi": false,
|
| 3 |
+
"singleQuote": true,
|
| 4 |
+
"tabWidth": 2,
|
| 5 |
+
"trailingComma": "es5",
|
| 6 |
+
"printWidth": 100,
|
| 7 |
+
"plugins": ["prettier-plugin-tailwindcss"]
|
| 8 |
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Todo App Frontend - Phase 2
|
| 2 |
+
|
| 3 |
+
Next.js 14 frontend for the Todo SaaS application with premium UI and authentication.
|
| 4 |
+
|
| 5 |
+
## Tech Stack
|
| 6 |
+
|
| 7 |
+
- **Next.js 14** - React framework with App Router
|
| 8 |
+
- **TypeScript** - Type-safe development
|
| 9 |
+
- **Tailwind CSS** - Utility-first CSS framework
|
| 10 |
+
- **shadcn/ui** - Premium UI components
|
| 11 |
+
- **Framer Motion** - Smooth animations
|
| 12 |
+
- **next-themes** - Dark/light theme support
|
| 13 |
+
|
| 14 |
+
## Setup
|
| 15 |
+
|
| 16 |
+
### 1. Install dependencies
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
npm install
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### 2. Setup environment
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
cp .env.example .env.local
|
| 26 |
+
# Edit .env.local with your configuration
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### 3. Start development server
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
npm run dev
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
App will be available at: http://localhost:3000
|
| 36 |
+
|
| 37 |
+
## Project Structure
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
frontend/
|
| 41 |
+
βββ src/
|
| 42 |
+
β βββ app/ # Next.js App Router pages
|
| 43 |
+
β βββ components/ # React components
|
| 44 |
+
β β βββ ui/ # shadcn/ui components
|
| 45 |
+
β βββ hooks/ # Custom React hooks
|
| 46 |
+
β βββ lib/ # Utility functions
|
| 47 |
+
β βββ styles/ # Global styles
|
| 48 |
+
β βββ types/ # TypeScript type definitions
|
| 49 |
+
βββ public/ # Static assets
|
| 50 |
+
βββ package.json # Dependencies and scripts
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
## Available Scripts
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
# Development
|
| 57 |
+
npm run dev # Start dev server
|
| 58 |
+
|
| 59 |
+
# Building
|
| 60 |
+
npm run build # Build for production
|
| 61 |
+
npm run start # Start production server
|
| 62 |
+
|
| 63 |
+
# Testing
|
| 64 |
+
npm test # Run unit tests
|
| 65 |
+
npm run test:watch # Watch mode
|
| 66 |
+
npm run test:e2e # Run E2E tests with Playwright
|
| 67 |
+
|
| 68 |
+
# Code Quality
|
| 69 |
+
npm run lint # Run ESLint
|
| 70 |
+
npm run lint:fix # Fix ESLint issues
|
| 71 |
+
npm run format # Format with Prettier
|
| 72 |
+
npm run type-check # TypeScript type checking
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Features
|
| 76 |
+
|
| 77 |
+
### Authentication
|
| 78 |
+
- Login with email/password
|
| 79 |
+
- User registration with validation
|
| 80 |
+
- Secure JWT token storage
|
| 81 |
+
- Auto-redirect based on auth state
|
| 82 |
+
|
| 83 |
+
### Todo Management
|
| 84 |
+
- Create, edit, delete todos
|
| 85 |
+
- Mark todos as complete
|
| 86 |
+
- Filter by status
|
| 87 |
+
- Search todos
|
| 88 |
+
- Sort by date, priority
|
| 89 |
+
|
| 90 |
+
### User Profile
|
| 91 |
+
- View and edit profile
|
| 92 |
+
- Upload avatar (Cloudinary)
|
| 93 |
+
- Update name and email
|
| 94 |
+
|
| 95 |
+
### AI Features
|
| 96 |
+
- Generate todos from text
|
| 97 |
+
- Summarize tasks
|
| 98 |
+
- Prioritize tasks
|
| 99 |
+
|
| 100 |
+
### UI/UX
|
| 101 |
+
- Dark/light theme toggle
|
| 102 |
+
- Smooth animations
|
| 103 |
+
- Mobile responsive
|
| 104 |
+
- Loading states
|
| 105 |
+
- Error handling
|
| 106 |
+
|
| 107 |
+
## Environment Variables
|
| 108 |
+
|
| 109 |
+
See `.env.example` for required environment variables:
|
| 110 |
+
|
| 111 |
+
```env
|
| 112 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 113 |
+
NEXT_PUBLIC_APP_ENV=development
|
| 114 |
+
NEXT_PUBLIC_ENABLE_AI=true
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
## Component Library
|
| 118 |
+
|
| 119 |
+
This project uses [shadcn/ui](https://ui.shadcn.com/) for premium UI components.
|
| 120 |
+
|
| 121 |
+
### Adding new components
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
npx shadcn-ui@latest add [component-name]
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Available components:
|
| 128 |
+
- Button, Input, Label, Card
|
| 129 |
+
- Dialog, Dropdown Menu, Select
|
| 130 |
+
- Tabs, Switch, Avatar
|
| 131 |
+
- Toast, and more...
|
| 132 |
+
|
| 133 |
+
## State Management
|
| 134 |
+
|
| 135 |
+
- React Context for auth state
|
| 136 |
+
- React hooks for local state
|
| 137 |
+
- Server Components for data fetching
|
| 138 |
+
- Client Components for interactivity
|
| 139 |
+
|
| 140 |
+
## Styling
|
| 141 |
+
|
| 142 |
+
- **Tailwind CSS** - Utility classes
|
| 143 |
+
- **CSS Variables** - Theme customization
|
| 144 |
+
- **Framer Motion** - Animations
|
| 145 |
+
- **shadcn/ui** - Pre-built components
|
| 146 |
+
|
| 147 |
+
## Testing
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
# Unit tests
|
| 151 |
+
npm test
|
| 152 |
+
|
| 153 |
+
# E2E tests
|
| 154 |
+
npm run test:e2e
|
| 155 |
+
|
| 156 |
+
# E2E with UI
|
| 157 |
+
npm run e2e:ui
|
| 158 |
+
|
| 159 |
+
# E2E debug mode
|
| 160 |
+
npm run e2e:debug
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
## Building for Production
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
# Build
|
| 167 |
+
npm run build
|
| 168 |
+
|
| 169 |
+
# Test production build locally
|
| 170 |
+
npm run start
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
## Deployment
|
| 174 |
+
|
| 175 |
+
This app is designed to be deployed on **Vercel**:
|
| 176 |
+
|
| 177 |
+
1. Push code to GitHub
|
| 178 |
+
2. Import project in Vercel
|
| 179 |
+
3. Configure environment variables
|
| 180 |
+
4. Deploy
|
| 181 |
+
|
| 182 |
+
## Browser Support
|
| 183 |
+
|
| 184 |
+
- Chrome (last 2 versions)
|
| 185 |
+
- Firefox (last 2 versions)
|
| 186 |
+
- Safari (last 2 versions)
|
| 187 |
+
- Edge (last 2 versions)
|
| 188 |
+
|
| 189 |
+
## License
|
| 190 |
+
|
| 191 |
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "slate",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
}
|
| 20 |
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Learn more: https://github.com/testing-library/jest-dom
|
| 2 |
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
|
| 4 |
+
// NOTE: This file should not be edited
|
| 5 |
+
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
swcMinify: true,
|
| 5 |
+
|
| 6 |
+
env: {
|
| 7 |
+
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
|
| 8 |
+
},
|
| 9 |
+
|
| 10 |
+
// Enable experimental features for better performance
|
| 11 |
+
experimental: {
|
| 12 |
+
optimizePackageImports: ['lucide-react', '@radix-ui/react-icons'],
|
| 13 |
+
},
|
| 14 |
+
|
| 15 |
+
// Image optimization
|
| 16 |
+
images: {
|
| 17 |
+
remotePatterns: [
|
| 18 |
+
{
|
| 19 |
+
protocol: 'https',
|
| 20 |
+
hostname: 'res.cloudinary.com',
|
| 21 |
+
},
|
| 22 |
+
],
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
// Webpack configuration
|
| 26 |
+
webpack: (config) => {
|
| 27 |
+
config.externals = [...(config.externals || []), { canvas: 'canvas' }];
|
| 28 |
+
|
| 29 |
+
// Handle Windows case sensitivity issues
|
| 30 |
+
config.resolve.symlinks = false;
|
| 31 |
+
config.snapshot = {
|
| 32 |
+
...config.snapshot,
|
| 33 |
+
managedPaths: [/^(.+?[\\/]node_modules[\\/])/],
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return config;
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
// Headers for security
|
| 40 |
+
async headers() {
|
| 41 |
+
return [
|
| 42 |
+
{
|
| 43 |
+
source: '/:path*',
|
| 44 |
+
headers: [
|
| 45 |
+
{
|
| 46 |
+
key: 'X-DNS-Prefetch-Control',
|
| 47 |
+
value: 'on'
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
key: 'X-Frame-Options',
|
| 51 |
+
value: 'SAMEORIGIN'
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
key: 'X-Content-Type-Options',
|
| 55 |
+
value: 'nosniff'
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
key: 'Referrer-Policy',
|
| 59 |
+
value: 'origin-when-cross-origin'
|
| 60 |
+
},
|
| 61 |
+
],
|
| 62 |
+
},
|
| 63 |
+
];
|
| 64 |
+
},
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
module.exports = nextConfig;
|
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "todo-app-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint",
|
| 10 |
+
"lint:fix": "next lint --fix",
|
| 11 |
+
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
| 12 |
+
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
| 13 |
+
"type-check": "tsc --noEmit",
|
| 14 |
+
"test": "jest",
|
| 15 |
+
"test:watch": "jest --watch",
|
| 16 |
+
"test:coverage": "jest --coverage",
|
| 17 |
+
"test:e2e": "playwright test",
|
| 18 |
+
"e2e:ui": "playwright test --ui",
|
| 19 |
+
"e2e:debug": "playwright test --debug"
|
| 20 |
+
},
|
| 21 |
+
"dependencies": {
|
| 22 |
+
"next": "14.1.0",
|
| 23 |
+
"react": "^18.2.0",
|
| 24 |
+
"react-dom": "^18.2.0",
|
| 25 |
+
"@radix-ui/react-avatar": "^1.0.4",
|
| 26 |
+
"@radix-ui/react-dialog": "^1.0.5",
|
| 27 |
+
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
| 28 |
+
"@radix-ui/react-label": "^2.0.2",
|
| 29 |
+
"@radix-ui/react-select": "^2.0.0",
|
| 30 |
+
"@radix-ui/react-slot": "^1.0.2",
|
| 31 |
+
"@radix-ui/react-switch": "^1.0.3",
|
| 32 |
+
"@radix-ui/react-tabs": "^1.0.4",
|
| 33 |
+
"@radix-ui/react-toast": "^1.1.5",
|
| 34 |
+
"framer-motion": "^11.0.0",
|
| 35 |
+
"next-themes": "^0.2.1",
|
| 36 |
+
"class-variance-authority": "^0.7.0",
|
| 37 |
+
"clsx": "^2.1.0",
|
| 38 |
+
"tailwind-merge": "^2.2.0",
|
| 39 |
+
"tailwindcss-animate": "^1.0.7",
|
| 40 |
+
"lucide-react": "^0.323.0",
|
| 41 |
+
"zod": "^3.22.0",
|
| 42 |
+
"date-fns": "^3.0.0"
|
| 43 |
+
},
|
| 44 |
+
"devDependencies": {
|
| 45 |
+
"@types/node": "^20",
|
| 46 |
+
"@types/react": "^18",
|
| 47 |
+
"@types/react-dom": "^18",
|
| 48 |
+
"typescript": "^5",
|
| 49 |
+
"tailwindcss": "^3.4.0",
|
| 50 |
+
"postcss": "^8",
|
| 51 |
+
"autoprefixer": "^10.0.1",
|
| 52 |
+
"eslint": "^8",
|
| 53 |
+
"eslint-config-next": "14.1.0",
|
| 54 |
+
"eslint-config-prettier": "^9.1.0",
|
| 55 |
+
"prettier": "^3.2.0",
|
| 56 |
+
"prettier-plugin-tailwindcss": "^0.5.0",
|
| 57 |
+
"@testing-library/react": "^14.0.0",
|
| 58 |
+
"@testing-library/jest-dom": "^6.0.0",
|
| 59 |
+
"@playwright/test": "^1.40.0",
|
| 60 |
+
"jest": "^29.0.0",
|
| 61 |
+
"jest-environment-jsdom": "^29.0.0"
|
| 62 |
+
}
|
| 63 |
+
}
|