Merge pull request #15 from fullsizemalt/claude/mvp-implementation-backend-2025-11-18
feat(backend): implement 7-MVP FastAPI backend Complete FastAPI backend with: - All 7 MVP models (blog, forum, merch, podcast, profiles, resources, tribute) - SQLAlchemy ORM with PostgreSQL - API endpoint stubs for all MVPs - Docker & Docker Compose for production deployment - Authentication models (RefreshToken, AuthAuditLog) - Full requirements.txt and configuration Ready for: - Database migration generation - Authentication implementation - Production deployment to nexus-vector Job ID: MTAD-IMPL-2025-11-18-CL
This commit is contained in:
commit
19271a1af5
27 changed files with 1624 additions and 0 deletions
65
backend/.gitignore
vendored
Normal file
65
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Migrations (until created)
|
||||
migrations/versions/*.py
|
||||
52
backend/Dockerfile
Normal file
52
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Multi-stage build for Python FastAPI backend
|
||||
# Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Python packages from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# Set environment variables
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
350
backend/README.md
Normal file
350
backend/README.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# MoreThanADiagnosis Backend API
|
||||
|
||||
FastAPI backend for the MoreThanADiagnosis community platform.
|
||||
|
||||
**Job ID**: MTAD-IMPL-2025-11-18-CL
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
- **Framework**: FastAPI with Python 3.11
|
||||
- **Database**: PostgreSQL with SQLAlchemy ORM
|
||||
- **Cache**: Redis
|
||||
- **Deployment**: Docker + Docker Compose on nexus-vector
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/v1/ # API endpoints for 7 MVPs
|
||||
│ │ ├── blog.py
|
||||
│ │ ├── forum.py
|
||||
│ │ ├── merch.py
|
||||
│ │ ├── podcast.py
|
||||
│ │ ├── profiles.py
|
||||
│ │ ├── resources.py
|
||||
│ │ ├── tribute.py
|
||||
│ │ └── health.py
|
||||
│ ├── models/ # SQLAlchemy models (7 MVPs + auth)
|
||||
│ ├── schemas/ # Pydantic schemas (TODO)
|
||||
│ ├── services/ # Business logic (TODO)
|
||||
│ ├── middleware/ # Custom middleware (TODO)
|
||||
│ ├── utils/ # Utilities (TODO)
|
||||
│ ├── config.py # Configuration
|
||||
│ ├── database.py # Database setup
|
||||
│ └── main.py # FastAPI app entry
|
||||
├── migrations/ # Alembic migrations (TODO)
|
||||
├── tests/ # Unit and integration tests (TODO)
|
||||
├── Dockerfile # Container image
|
||||
├── docker-compose.yml # Local development setup
|
||||
├── requirements.txt # Python dependencies
|
||||
└── .env.example # Environment variables template
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Python 3.11+ (for local development)
|
||||
- PostgreSQL (if not using Docker)
|
||||
|
||||
### Local Development
|
||||
|
||||
1. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Start services**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Run migrations** (TODO)
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
5. **Start API**
|
||||
```bash
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
6. **Access API**
|
||||
- API: http://localhost:8000
|
||||
- Docs: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
|
||||
### Production Deployment (nexus-vector)
|
||||
|
||||
1. **Copy to nexus-vector**
|
||||
```bash
|
||||
scp -r backend/ admin@nexus-vector:/srv/containers/mtad-backend/
|
||||
```
|
||||
|
||||
2. **Set production environment**
|
||||
```bash
|
||||
ssh admin@nexus-vector
|
||||
cd /srv/containers/mtad-backend
|
||||
cp .env.example .env
|
||||
# Edit .env with production secrets
|
||||
```
|
||||
|
||||
3. **Deploy with Docker Compose**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Verify health**
|
||||
```bash
|
||||
curl http://100.95.3.92:8000/health
|
||||
```
|
||||
|
||||
## 📋 MVPs Implemented (Alphabetically)
|
||||
|
||||
### 1. Blog MVP
|
||||
- List published blog posts
|
||||
- Get individual posts by ID
|
||||
- (TODO) Create, update, delete posts with authentication
|
||||
|
||||
### 2. Forum MVP
|
||||
- List categories
|
||||
- List threads per category
|
||||
- List posts per thread
|
||||
- (TODO) Create threads and posts with authentication
|
||||
- (TODO) Emoji reactions and moderation reports
|
||||
|
||||
### 3. Merch MVP
|
||||
- List products
|
||||
- Get product details
|
||||
- List orders
|
||||
- (TODO) Create orders with authentication
|
||||
|
||||
### 4. Podcast MVP
|
||||
- List episodes
|
||||
- Get episode details
|
||||
- (TODO) Upload and manage episodes
|
||||
|
||||
### 5. Profiles MVP
|
||||
- Get user profile
|
||||
- List public profiles
|
||||
- (TODO) Update profile with authentication
|
||||
|
||||
### 6. Resources MVP
|
||||
- List resources by access tier
|
||||
- Get resources by ID or slug
|
||||
- (TODO) Create resources with authorization
|
||||
|
||||
### 7. Tribute MVP
|
||||
- List published tributes
|
||||
- Get tribute details
|
||||
- (TODO) Create tributes with authentication
|
||||
|
||||
## 🔐 Authentication & Authorization
|
||||
|
||||
**Status**: Specification approved (openspec/specs/authentication.md)
|
||||
|
||||
**TODO Implementation**:
|
||||
- OAuth2/OIDC with PKCE
|
||||
- JWT access tokens (15 min expiry)
|
||||
- Refresh tokens with rotation (30 day expiry)
|
||||
- RBAC: member, moderator, admin roles
|
||||
- MFA (TOTP) support
|
||||
- Password reset flow
|
||||
- Account lockout protection
|
||||
|
||||
## 🗄 Database
|
||||
|
||||
**Status**: Schema specified (openspec/specs/data-model.md)
|
||||
|
||||
**Core Entities**:
|
||||
- User (with email verification, MFA)
|
||||
- Profile (display name, pseudonym, health journey)
|
||||
- Forum (categories, threads, posts, reactions, reports)
|
||||
- Blog (posts)
|
||||
- Podcast (episodes)
|
||||
- Resources (knowledge base)
|
||||
- Tribute (memorials)
|
||||
- Merch (products, orders)
|
||||
- Consent (privacy compliance)
|
||||
|
||||
**Data Classification**:
|
||||
- Public: Forum categories, public resources, published blog/podcast
|
||||
- PII: Email, display names, shipping addresses, avatars
|
||||
- PHI: Health journey, forum content (context-dependent), memorials
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
All endpoints use `/api/v1/` prefix.
|
||||
|
||||
### Blog
|
||||
- `GET /api/v1/blog/` - List posts
|
||||
- `GET /api/v1/blog/{post_id}` - Get post
|
||||
|
||||
### Forum
|
||||
- `GET /api/v1/forum/categories` - List categories
|
||||
- `GET /api/v1/forum/categories/{cat_id}/threads` - List threads
|
||||
- `GET /api/v1/forum/threads/{thread_id}/posts` - List posts
|
||||
|
||||
### Merch
|
||||
- `GET /api/v1/merch/products` - List products
|
||||
- `GET /api/v1/merch/products/{id}` - Get product
|
||||
- `GET /api/v1/merch/orders/{id}` - Get order
|
||||
|
||||
### Podcast
|
||||
- `GET /api/v1/podcast/episodes` - List episodes
|
||||
- `GET /api/v1/podcast/episodes/{id}` - Get episode
|
||||
|
||||
### Profiles
|
||||
- `GET /api/v1/profiles/{user_id}` - Get profile
|
||||
- `GET /api/v1/profiles/` - List public profiles
|
||||
|
||||
### Resources
|
||||
- `GET /api/v1/resources/` - List resources
|
||||
- `GET /api/v1/resources/{id}` - Get resource
|
||||
- `GET /api/v1/resources/slug/{slug}` - Get by slug
|
||||
|
||||
### Tribute
|
||||
- `GET /api/v1/tribute/` - List tributes
|
||||
- `GET /api/v1/tribute/{id}` - Get tribute
|
||||
|
||||
### Health
|
||||
- `GET /api/v1/health` - Health check
|
||||
- `GET /api/v1/ready` - Readiness check
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
**Status**: Test infrastructure ready (pytest, pytest-asyncio)
|
||||
|
||||
**TODO**:
|
||||
- Unit tests for models
|
||||
- Integration tests for endpoints
|
||||
- API contract tests
|
||||
- Load testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Coverage report
|
||||
pytest tests/ --cov=app --cov-report=html
|
||||
```
|
||||
|
||||
## 🔄 Database Migrations
|
||||
|
||||
**Status**: Alembic configured, no migrations yet
|
||||
|
||||
```bash
|
||||
# Create migration
|
||||
alembic revision --autogenerate -m "Add forum tables"
|
||||
|
||||
# Apply migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## 📝 Development Checklist
|
||||
|
||||
### Phase 1: Foundation (Current)
|
||||
- [x] FastAPI project structure
|
||||
- [x] Database models for all 7 MVPs
|
||||
- [x] API endpoint stubs
|
||||
- [x] Docker configuration
|
||||
- [ ] Environment configuration finalization
|
||||
- [ ] Database migrations
|
||||
- [ ] Test infrastructure setup
|
||||
|
||||
### Phase 2: Authentication (Next)
|
||||
- [ ] User registration/login
|
||||
- [ ] Email verification
|
||||
- [ ] Password hashing (Argon2)
|
||||
- [ ] JWT token generation
|
||||
- [ ] RBAC middleware
|
||||
- [ ] MFA setup
|
||||
|
||||
### Phase 3: MVP Implementation
|
||||
- [ ] Blog: Full CRUD with publishing
|
||||
- [ ] Forum: Threading, moderation, reporting
|
||||
- [ ] Merch: Shopping cart, checkout
|
||||
- [ ] Podcast: Upload, streaming
|
||||
- [ ] Profiles: User data management
|
||||
- [ ] Resources: Knowledge base
|
||||
- [ ] Tribute: Memorial management
|
||||
|
||||
### Phase 4: Integration & Deployment
|
||||
- [ ] Frontend integration (Next.js web)
|
||||
- [ ] Mobile integration (React Native/Expo)
|
||||
- [ ] Production deployment
|
||||
- [ ] Monitoring & alerting
|
||||
- [ ] Performance optimization
|
||||
|
||||
## 🛠 Configuration
|
||||
|
||||
See `.env.example` for all available environment variables.
|
||||
|
||||
**Key Production Variables**:
|
||||
```
|
||||
ENV=production
|
||||
DEBUG=false
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
SECRET_KEY=<generate-with-secrets>
|
||||
CORS_ORIGINS=["https://morethanadiagnosis.com"]
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **OpenAPI/Swagger**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
- **Architecture Spec**: ../../openspec/specs/architecture.md
|
||||
- **Data Model**: ../../openspec/specs/data-model.md
|
||||
- **Authentication Spec**: ../../openspec/specs/authentication.md
|
||||
- **Design System**: ../../openspec/specs/design-system.md
|
||||
|
||||
## 🚨 Known Issues
|
||||
|
||||
- [ ] Authentication not implemented yet
|
||||
- [ ] Database migrations not created yet
|
||||
- [ ] Request/response schemas not defined
|
||||
- [ ] Error handling needs standardization
|
||||
- [ ] Logging configuration needs refinement
|
||||
- [ ] Rate limiting not configured
|
||||
- [ ] CORS not finalized for production
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
See `requirements.txt` for full list. Key dependencies:
|
||||
|
||||
- **fastapi** - Web framework
|
||||
- **sqlalchemy** - ORM
|
||||
- **alembic** - Migrations
|
||||
- **pydantic** - Validation
|
||||
- **passlib/argon2-cffi** - Password hashing
|
||||
- **python-jose** - JWT
|
||||
- **pytest** - Testing
|
||||
- **uvicorn** - ASGI server
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow OpenSpec lifecycle (propose → review → apply → archive)
|
||||
2. Ensure all code links to approved specs
|
||||
3. Add tests for new features
|
||||
4. Update documentation
|
||||
5. Follow Python/FastAPI best practices
|
||||
|
||||
## 📄 License
|
||||
|
||||
Part of MoreThanADiagnosis community platform.
|
||||
|
||||
---
|
||||
|
||||
**Status**: Foundation complete, MVP implementation ready
|
||||
**Next Action**: Database migrations + authentication implementation
|
||||
**Job ID**: MTAD-IMPL-2025-11-18-CL
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""API package."""
|
||||
22
backend/app/api/v1/__init__.py
Normal file
22
backend/app/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""
|
||||
API v1 router aggregation.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import blog, forum, merch, podcast, profiles, resources, tribute, health
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include routers for all 7 MVPs
|
||||
api_router.include_router(blog.router, prefix="/blog", tags=["Blog"])
|
||||
api_router.include_router(forum.router, prefix="/forum", tags=["Forum"])
|
||||
api_router.include_router(merch.router, prefix="/merch", tags=["Merch"])
|
||||
api_router.include_router(podcast.router, prefix="/podcast", tags=["Podcast"])
|
||||
api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"])
|
||||
api_router.include_router(resources.router, prefix="/resources", tags=["Resources"])
|
||||
api_router.include_router(tribute.router, prefix="/tribute", tags=["Tribute"])
|
||||
api_router.include_router(health.router, tags=["Health"])
|
||||
|
||||
__all__ = ["api_router"]
|
||||
49
backend/app/api/v1/blog.py
Normal file
49
backend/app/api/v1/blog.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""
|
||||
Blog MVP API endpoints.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import BlogPost
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_blog_posts(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
|
||||
"""List all published blog posts."""
|
||||
posts = db.query(BlogPost).filter(BlogPost.published_at.isnot(None)).offset(skip).limit(limit).all()
|
||||
return {"items": posts, "count": len(posts)}
|
||||
|
||||
|
||||
@router.get("/{post_id}")
|
||||
async def get_blog_post(post_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a specific blog post by ID."""
|
||||
post = db.query(BlogPost).filter(BlogPost.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blog post not found")
|
||||
return post
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_blog_post(title: str, slug: str, content: str, db: Session = Depends(get_db)):
|
||||
"""Create a new blog post (requires authentication)."""
|
||||
# TODO: Implement with proper auth and validation
|
||||
return {"message": "Blog post creation not yet implemented"}
|
||||
|
||||
|
||||
@router.put("/{post_id}")
|
||||
async def update_blog_post(post_id: str, title: str, content: str, db: Session = Depends(get_db)):
|
||||
"""Update a blog post (requires authentication and ownership)."""
|
||||
# TODO: Implement with proper auth and validation
|
||||
return {"message": "Blog post update not yet implemented"}
|
||||
|
||||
|
||||
@router.delete("/{post_id}")
|
||||
async def delete_blog_post(post_id: str, db: Session = Depends(get_db)):
|
||||
"""Delete a blog post (requires authentication and ownership)."""
|
||||
# TODO: Implement with proper auth and validation
|
||||
return {"message": "Blog post deletion not yet implemented"}
|
||||
40
backend/app/api/v1/forum.py
Normal file
40
backend/app/api/v1/forum.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Forum MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import ForumCategory, ForumThread, ForumPost
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(db: Session = Depends(get_db)):
|
||||
"""List all forum categories."""
|
||||
categories = db.query(ForumCategory).order_by(ForumCategory.order).all()
|
||||
return {"items": categories}
|
||||
|
||||
@router.get("/categories/{category_id}/threads")
|
||||
async def list_threads(category_id: str, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)):
|
||||
"""List threads in a category."""
|
||||
threads = db.query(ForumThread).filter(ForumThread.category_id == category_id).offset(skip).limit(limit).all()
|
||||
return {"items": threads}
|
||||
|
||||
@router.get("/threads/{thread_id}/posts")
|
||||
async def list_posts(thread_id: str, skip: int = 0, limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""List posts in a thread."""
|
||||
posts = db.query(ForumPost).filter(ForumPost.thread_id == thread_id, ForumPost.deleted_at.is_(None)).offset(skip).limit(limit).all()
|
||||
return {"items": posts}
|
||||
|
||||
@router.post("/categories")
|
||||
async def create_category(name: str, db: Session = Depends(get_db)):
|
||||
"""Create forum category (admin only)."""
|
||||
return {"message": "Category creation not yet implemented"}
|
||||
|
||||
@router.post("/threads")
|
||||
async def create_thread(category_id: str, title: str, db: Session = Depends(get_db)):
|
||||
"""Create forum thread (requires authentication)."""
|
||||
return {"message": "Thread creation not yet implemented"}
|
||||
|
||||
@router.post("/posts")
|
||||
async def create_post(thread_id: str, content: str, db: Session = Depends(get_db)):
|
||||
"""Create forum post (requires authentication)."""
|
||||
return {"message": "Post creation not yet implemented"}
|
||||
14
backend/app/api/v1/health.py
Normal file
14
backend/app/api/v1/health.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""Health check endpoint. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Health check for uptime monitoring."""
|
||||
return {"status": "healthy", "service": "MoreThanADiagnosis API"}
|
||||
|
||||
@router.get("/ready")
|
||||
async def readiness_check():
|
||||
"""Readiness check for load balancer."""
|
||||
return {"status": "ready"}
|
||||
39
backend/app/api/v1/merch.py
Normal file
39
backend/app/api/v1/merch.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Merch MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import MerchProduct, Order
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/products")
|
||||
async def list_products(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""List all merch products."""
|
||||
products = db.query(MerchProduct).offset(skip).limit(limit).all()
|
||||
return {"items": products}
|
||||
|
||||
@router.get("/products/{product_id}")
|
||||
async def get_product(product_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a specific product."""
|
||||
product = db.query(MerchProduct).filter(MerchProduct.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
|
||||
return product
|
||||
|
||||
@router.get("/orders/{order_id}")
|
||||
async def get_order(order_id: str, db: Session = Depends(get_db)):
|
||||
"""Get order details."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found")
|
||||
return order
|
||||
|
||||
@router.post("/products")
|
||||
async def create_product(name: str, price: float, db: Session = Depends(get_db)):
|
||||
"""Create product (admin only)."""
|
||||
return {"message": "Product creation not yet implemented"}
|
||||
|
||||
@router.post("/orders")
|
||||
async def create_order(items: list, db: Session = Depends(get_db)):
|
||||
"""Create order (requires authentication)."""
|
||||
return {"message": "Order creation not yet implemented"}
|
||||
27
backend/app/api/v1/podcast.py
Normal file
27
backend/app/api/v1/podcast.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Podcast MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import PodcastEpisode
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/episodes")
|
||||
async def list_episodes(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""List all podcast episodes."""
|
||||
episodes = db.query(PodcastEpisode).filter(PodcastEpisode.published_at.isnot(None)).order_by(PodcastEpisode.published_at.desc()).offset(skip).limit(limit).all()
|
||||
return {"items": episodes}
|
||||
|
||||
@router.get("/episodes/{episode_id}")
|
||||
async def get_episode(episode_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a specific episode."""
|
||||
episode = db.query(PodcastEpisode).filter(PodcastEpisode.id == episode_id).first()
|
||||
if not episode:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Episode not found")
|
||||
return episode
|
||||
|
||||
@router.post("/episodes")
|
||||
async def create_episode(title: str, audio_url: str, db: Session = Depends(get_db)):
|
||||
"""Create podcast episode (admin only)."""
|
||||
return {"message": "Episode creation not yet implemented"}
|
||||
26
backend/app/api/v1/profiles.py
Normal file
26
backend/app/api/v1/profiles.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Profiles MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, Profile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_profile(user_id: str, db: Session = Depends(get_db)):
|
||||
"""Get user profile."""
|
||||
profile = db.query(Profile).filter(Profile.user_id == user_id).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
return profile
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_profile(user_id: str, display_name: str = None, bio: str = None, db: Session = Depends(get_db)):
|
||||
"""Update profile (requires authentication)."""
|
||||
return {"message": "Profile update not yet implemented"}
|
||||
|
||||
@router.get("/")
|
||||
async def list_public_profiles(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""List public user profiles."""
|
||||
profiles = db.query(Profile).offset(skip).limit(limit).all()
|
||||
return {"items": profiles}
|
||||
34
backend/app/api/v1/resources.py
Normal file
34
backend/app/api/v1/resources.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Resources MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import Resource
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def list_resources(access_tier: str = "public", skip: int = 0, limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""List resources by access tier."""
|
||||
resources = db.query(Resource).filter(Resource.access_tier == access_tier).offset(skip).limit(limit).all()
|
||||
return {"items": resources}
|
||||
|
||||
@router.get("/{resource_id}")
|
||||
async def get_resource(resource_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a specific resource."""
|
||||
resource = db.query(Resource).filter(Resource.id == resource_id).first()
|
||||
if not resource:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
|
||||
return resource
|
||||
|
||||
@router.get("/slug/{slug}")
|
||||
async def get_resource_by_slug(slug: str, db: Session = Depends(get_db)):
|
||||
"""Get resource by slug."""
|
||||
resource = db.query(Resource).filter(Resource.slug == slug).first()
|
||||
if not resource:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
|
||||
return resource
|
||||
|
||||
@router.post("/")
|
||||
async def create_resource(title: str, slug: str, content: str, db: Session = Depends(get_db)):
|
||||
"""Create resource (admin or authorized user)."""
|
||||
return {"message": "Resource creation not yet implemented"}
|
||||
31
backend/app/api/v1/tribute.py
Normal file
31
backend/app/api/v1/tribute.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Tribute MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import TributeEntry
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def list_tributes(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)):
|
||||
"""List published tribute entries."""
|
||||
entries = db.query(TributeEntry).filter(TributeEntry.published == True).offset(skip).limit(limit).all()
|
||||
return {"items": entries}
|
||||
|
||||
@router.get("/{tribute_id}")
|
||||
async def get_tribute(tribute_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a specific tribute entry."""
|
||||
entry = db.query(TributeEntry).filter(TributeEntry.id == tribute_id, TributeEntry.published == True).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tribute not found")
|
||||
return entry
|
||||
|
||||
@router.post("/")
|
||||
async def create_tribute(subject_name: str, memorial_text: str, db: Session = Depends(get_db)):
|
||||
"""Create tribute entry (requires authentication)."""
|
||||
return {"message": "Tribute creation not yet implemented"}
|
||||
|
||||
@router.put("/{tribute_id}")
|
||||
async def update_tribute(tribute_id: str, memorial_text: str = None, db: Session = Depends(get_db)):
|
||||
"""Update tribute (requires authentication and ownership)."""
|
||||
return {"message": "Tribute update not yet implemented"}
|
||||
86
backend/app/config.py
Normal file
86
backend/app/config.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""
|
||||
Application configuration using Pydantic Settings.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Environment
|
||||
env: str = "development"
|
||||
debug: bool = False
|
||||
|
||||
# API
|
||||
api_version: str = "v1"
|
||||
api_title: str = "MoreThanADiagnosis API"
|
||||
api_description: str = "Community Hub for Chronically/Terminally Ill Individuals"
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:///./test.db"
|
||||
database_pool_size: int = 20
|
||||
database_max_overflow: int = 10
|
||||
|
||||
# Security
|
||||
secret_key: str = "change-me-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
password_hashing_algorithm: str = "argon2"
|
||||
|
||||
# CORS
|
||||
cors_origins: List[str] = [
|
||||
"https://morethanadiagnosis.com",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8081",
|
||||
]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: List[str] = ["*"]
|
||||
cors_headers: List[str] = ["*"]
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "json"
|
||||
|
||||
# Observability
|
||||
jaeger_enabled: bool = False
|
||||
jaeger_host: str = "localhost"
|
||||
jaeger_port: int = 6831
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 100
|
||||
rate_limit_period: int = 60
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Email
|
||||
smtp_server: str = "smtp.gmail.com"
|
||||
smtp_port: int = 587
|
||||
smtp_username: str = "noreply@morethanadiagnosis.com"
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "noreply@morethanadiagnosis.com"
|
||||
|
||||
# File Storage
|
||||
storage_type: str = "s3"
|
||||
s3_bucket: str = "morethanadiagnosis-media"
|
||||
s3_region: str = "us-east-1"
|
||||
s3_access_key: str = ""
|
||||
s3_secret_key: str = ""
|
||||
|
||||
# Feature Flags
|
||||
feature_mfa_enabled: bool = True
|
||||
feature_social_login_enabled: bool = False
|
||||
feature_pseudonym_enabled: bool = True
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
64
backend/app/database.py
Normal file
64
backend/app/database.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Database configuration and session management.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create database engine with connection pooling
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
poolclass=None if "sqlite" in settings.database_url else None,
|
||||
pool_size=settings.database_pool_size,
|
||||
max_overflow=settings.database_max_overflow,
|
||||
echo=settings.debug,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Event listeners for PostgreSQL
|
||||
@event.listens_for(Engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_conn, connection_record):
|
||||
"""Enable foreign keys for SQLite (if using SQLite)."""
|
||||
if "sqlite" in settings.database_url:
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for FastAPI to get database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables."""
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Close database connections."""
|
||||
engine.dispose()
|
||||
logger.info("Database connections closed")
|
||||
88
backend/app/main.py
Normal file
88
backend/app/main.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""
|
||||
FastAPI main application entry point.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.database import init_db, close_db
|
||||
from app.api.v1 import api_router
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown events."""
|
||||
# Startup
|
||||
logger.info("Starting MoreThanADiagnosis API")
|
||||
await init_db()
|
||||
logger.info("Database initialized")
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down MoreThanADiagnosis API")
|
||||
await close_db()
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title=settings.api_title,
|
||||
description=settings.api_description,
|
||||
version=settings.api_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=settings.cors_credentials,
|
||||
allow_methods=settings.cors_methods,
|
||||
allow_headers=settings.cors_headers,
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix=f"/api/{settings.api_version}")
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint for uptime monitoring."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.api_version,
|
||||
"env": settings.env,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"service": settings.api_title,
|
||||
"version": settings.api_version,
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.debug,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
48
backend/app/models/__init__.py
Normal file
48
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""
|
||||
SQLAlchemy models package.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from app.models.user import User, Profile, Role, UserRole, Consent
|
||||
from app.models.authentication import RefreshToken, AuthAuditLog
|
||||
from app.models.forum import (
|
||||
ForumCategory,
|
||||
ForumThread,
|
||||
ForumPost,
|
||||
ForumReaction,
|
||||
ForumReport,
|
||||
)
|
||||
from app.models.blog import BlogPost
|
||||
from app.models.podcast import PodcastEpisode
|
||||
from app.models.resources import Resource
|
||||
from app.models.tribute import TributeEntry
|
||||
from app.models.merch import MerchProduct, Order
|
||||
|
||||
__all__ = [
|
||||
# User & Auth
|
||||
"User",
|
||||
"Profile",
|
||||
"Role",
|
||||
"UserRole",
|
||||
"Consent",
|
||||
"RefreshToken",
|
||||
"AuthAuditLog",
|
||||
# Forum
|
||||
"ForumCategory",
|
||||
"ForumThread",
|
||||
"ForumPost",
|
||||
"ForumReaction",
|
||||
"ForumReport",
|
||||
# Blog
|
||||
"BlogPost",
|
||||
# Podcast
|
||||
"PodcastEpisode",
|
||||
# Resources
|
||||
"Resource",
|
||||
# Tribute
|
||||
"TributeEntry",
|
||||
# Merch
|
||||
"MerchProduct",
|
||||
"Order",
|
||||
]
|
||||
42
backend/app/models/authentication.py
Normal file
42
backend/app/models/authentication.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
Authentication models - RefreshToken and AuthAuditLog.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""Refresh Token entity - Session management."""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
token_hash = Column(String(255), unique=True, nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False, index=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="refresh_tokens")
|
||||
|
||||
|
||||
class AuthAuditLog(Base):
|
||||
"""Auth Audit Log - Compliance and security auditing."""
|
||||
|
||||
__tablename__ = "auth_audit_logs"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=True)
|
||||
event_type = Column(String(50), index=True, nullable=False) # signup, login_success, login_fail, password_reset, mfa_enable, etc.
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
29
backend/app/models/blog.py
Normal file
29
backend/app/models/blog.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Blog MVP model - BlogPost.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BlogPost(Base):
|
||||
"""Blog Post - Published articles and content."""
|
||||
|
||||
__tablename__ = "blog_posts"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
author_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
slug = Column(String(500), unique=True, index=True, nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
excerpt = Column(Text, nullable=True)
|
||||
published_at = Column(DateTime, nullable=True, index=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
author = relationship("User", back_populates="blog_posts")
|
||||
102
backend/app/models/forum.py
Normal file
102
backend/app/models/forum.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
Forum MVP models - Categories, Threads, Posts, Reactions, Reports.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, DateTime, Integer, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ForumCategory(Base):
|
||||
"""Forum Category - Discussion categories."""
|
||||
|
||||
__tablename__ = "forum_categories"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
order = Column(Integer, default=0, index=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
threads = relationship("ForumThread", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ForumThread(Base):
|
||||
"""Forum Thread - Discussion thread."""
|
||||
|
||||
__tablename__ = "forum_threads"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
category_id = Column(String(36), ForeignKey("forum_categories.id"), index=True, nullable=False)
|
||||
author_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
pinned = Column(Boolean, default=False, index=True)
|
||||
locked = Column(Boolean, default=False, index=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
category = relationship("ForumCategory", back_populates="threads")
|
||||
author = relationship("User", back_populates="forum_threads")
|
||||
posts = relationship("ForumPost", back_populates="thread", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ForumPost(Base):
|
||||
"""Forum Post - Individual post/reply in a thread."""
|
||||
|
||||
__tablename__ = "forum_posts"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
thread_id = Column(String(36), ForeignKey("forum_threads.id"), index=True, nullable=False)
|
||||
author_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
parent_post_id = Column(String(36), ForeignKey("forum_posts.id"), index=True, nullable=True)
|
||||
content = Column(Text, nullable=False) # May contain PHI
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
thread = relationship("ForumThread", back_populates="posts")
|
||||
author = relationship("User", back_populates="forum_posts")
|
||||
reactions = relationship("ForumReaction", back_populates="post", cascade="all, delete-orphan")
|
||||
reports = relationship("ForumReport", back_populates="post", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ForumReaction(Base):
|
||||
"""Forum Reaction - Emoji reactions to posts."""
|
||||
|
||||
__tablename__ = "forum_reactions"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
post_id = Column(String(36), ForeignKey("forum_posts.id"), index=True, nullable=False)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
emoji_code = Column(String(20), nullable=False) # e.g., "👍", "❤️", "😢"
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
post = relationship("ForumPost", back_populates="reactions")
|
||||
user = relationship("User", back_populates="forum_reactions")
|
||||
|
||||
|
||||
class ForumReport(Base):
|
||||
"""Forum Report - Content moderation reports."""
|
||||
|
||||
__tablename__ = "forum_reports"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
post_id = Column(String(36), ForeignKey("forum_posts.id"), index=True, nullable=False)
|
||||
reporter_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
reason = Column(String(255), nullable=False)
|
||||
status = Column(String(50), default="open", index=True) # open, resolved, dismissed
|
||||
moderator_notes = Column(Text, nullable=True)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
post = relationship("ForumPost", back_populates="reports")
|
||||
reporter = relationship("User", back_populates="forum_reports")
|
||||
63
backend/app/models/merch.py
Normal file
63
backend/app/models/merch.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""
|
||||
Merch MVP models - MerchProduct and Order for commerce.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Integer, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MerchProduct(Base):
|
||||
"""Merch Product - Store products."""
|
||||
|
||||
__tablename__ = "merch_products"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
price = Column(Float, nullable=False)
|
||||
stock_count = Column(Integer, default=0)
|
||||
image_url = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
order_items = relationship("OrderItem", back_populates="product", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""Order - Purchase orders."""
|
||||
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
total = Column(Float, nullable=False)
|
||||
status = Column(String(50), default="pending", index=True) # pending, completed, cancelled
|
||||
shipping_address = Column(String(500), nullable=True) # PII - Encrypted
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="orders")
|
||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
"""Order Item - Individual items in an order."""
|
||||
|
||||
__tablename__ = "order_items"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
order_id = Column(String(36), ForeignKey("orders.id"), index=True, nullable=False)
|
||||
product_id = Column(String(36), ForeignKey("merch_products.id"), index=True, nullable=False)
|
||||
quantity = Column(Integer, default=1)
|
||||
price = Column(Float, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("MerchProduct", back_populates="order_items")
|
||||
25
backend/app/models/podcast.py
Normal file
25
backend/app/models/podcast.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
Podcast MVP model - PodcastEpisode.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PodcastEpisode(Base):
|
||||
"""Podcast Episode - Audio content episodes."""
|
||||
|
||||
__tablename__ = "podcast_episodes"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
audio_url = Column(String(500), nullable=False)
|
||||
duration = Column(Integer, nullable=True) # Duration in seconds
|
||||
episode_number = Column(Integer, nullable=True, index=True)
|
||||
published_at = Column(DateTime, nullable=True, index=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
29
backend/app/models/resources.py
Normal file
29
backend/app/models/resources.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Resources MVP model - Resource knowledge base.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Resource(Base):
|
||||
"""Resource - Knowledge base and documentation."""
|
||||
|
||||
__tablename__ = "resources"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
author_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=True)
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
slug = Column(String(500), unique=True, index=True, nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
access_tier = Column(String(20), default="public", index=True) # public, members
|
||||
tags = Column(JSON, default=[], index=False) # Array of tags
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True)
|
||||
|
||||
# Relationships
|
||||
author = relationship("User", back_populates="resources")
|
||||
27
backend/app/models/tribute.py
Normal file
27
backend/app/models/tribute.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""
|
||||
Tribute MVP model - TributeEntry for memorials.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TributeEntry(Base):
|
||||
"""Tribute Entry - Memorial and tribute entries."""
|
||||
|
||||
__tablename__ = "tribute_entries"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
author_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
subject_name = Column(String(255), nullable=False, index=True)
|
||||
memorial_text = Column(Text, nullable=False) # May contain PHI
|
||||
published = Column(Boolean, default=False, index=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
author = relationship("User", back_populates="tribute_entries")
|
||||
119
backend/app/models/user.py
Normal file
119
backend/app/models/user.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""
|
||||
User, Profile, Role, and Consent models.
|
||||
|
||||
Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User entity - Core authentication and identity."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
email_verified = Column(Boolean, default=False)
|
||||
mfa_enabled = Column(Boolean, default=False)
|
||||
mfa_secret = Column(String(255), nullable=True) # Encrypted
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
profile = relationship("Profile", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
roles = relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
|
||||
consents = relationship("Consent", back_populates="user", cascade="all, delete-orphan")
|
||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("AuthAuditLog", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
# Forum relationships
|
||||
forum_threads = relationship("ForumThread", back_populates="author")
|
||||
forum_posts = relationship("ForumPost", back_populates="author")
|
||||
forum_reactions = relationship("ForumReaction", back_populates="user")
|
||||
forum_reports = relationship("ForumReport", back_populates="reporter")
|
||||
|
||||
# Content relationships
|
||||
blog_posts = relationship("BlogPost", back_populates="author")
|
||||
resources = relationship("Resource", back_populates="author")
|
||||
tribute_entries = relationship("TributeEntry", back_populates="author")
|
||||
|
||||
# Commerce relationships
|
||||
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Profile(Base):
|
||||
"""User Profile - Extended user information and health journey."""
|
||||
|
||||
__tablename__ = "profiles"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False, index=True)
|
||||
display_name = Column(String(255), nullable=False)
|
||||
pseudonym = Column(String(255), nullable=True, index=True)
|
||||
pronouns = Column(String(50), nullable=True)
|
||||
avatar_url = Column(String(500), nullable=True)
|
||||
bio = Column(Text, nullable=True)
|
||||
health_journey = Column(Text, nullable=True) # PHI - Encrypted at rest
|
||||
consent_flags = Column(JSON, default={})
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="profile")
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""Role entity - RBAC roles."""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, index=True, nullable=False)
|
||||
permissions = Column(JSON, default={})
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
users = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""User-Role mapping for RBAC."""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
role_id = Column(String(36), ForeignKey("roles.id"), index=True, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="roles")
|
||||
role = relationship("Role", back_populates="users")
|
||||
|
||||
|
||||
class Consent(Base):
|
||||
"""Consent entity - User consent tracking for compliance."""
|
||||
|
||||
__tablename__ = "consents"
|
||||
|
||||
id = Column(String(36), primary_key=True, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), index=True, nullable=False)
|
||||
consent_type = Column(String(100), nullable=False) # e.g., marketing, analytics, data_sharing
|
||||
granted = Column(Boolean, default=False)
|
||||
granted_at = Column(DateTime, nullable=True)
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="consents")
|
||||
86
backend/docker-compose.yml
Normal file
86
backend/docker-compose.yml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
version: '3.8'
|
||||
|
||||
# Job ID: MTAD-IMPL-2025-11-18-CL
|
||||
# Deployment: nexus-vector (production)
|
||||
# Spec: openspec/specs/architecture.md
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: mtad-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change-me-in-production}
|
||||
POSTGRES_DB: morethanadiagnosis
|
||||
POSTGRES_INITDB_ARGS: "-c shared_preload_libraries=pg_stat_statements"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- mtad-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U admin -d morethanadiagnosis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mtad-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- mtad-network
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-change-me-in-production}
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mtad-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ENV: production
|
||||
DEBUG: "false"
|
||||
DATABASE_URL: postgresql://admin:${DB_PASSWORD:-change-me-in-production}@postgres:5432/morethanadiagnosis
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-change-me-in-production}@redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
CORS_ORIGINS: '["https://morethanadiagnosis.com", "http://localhost:3000"]'
|
||||
LOG_LEVEL: INFO
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mtad-network
|
||||
volumes:
|
||||
- ./app:/app/app:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
mtad-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
66
backend/requirements.txt
Normal file
66
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Core Framework
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
alembic==1.12.1
|
||||
psycopg2-binary==2.9.9
|
||||
psycopg[binary]==3.9.10
|
||||
|
||||
# Authentication & Security
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
PyJWT==2.8.1
|
||||
bcrypt==4.1.1
|
||||
cryptography==41.0.7
|
||||
argon2-cffi==23.1.0
|
||||
|
||||
# API Documentation
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Validation & Serialization
|
||||
email-validator==2.1.0
|
||||
|
||||
# Environment Variables
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Logging & Observability
|
||||
python-json-logger==2.0.7
|
||||
opentelemetry-api==1.21.0
|
||||
opentelemetry-sdk==1.21.0
|
||||
opentelemetry-exporter-jaeger==1.21.0
|
||||
opentelemetry-instrumentation-fastapi==0.42b0
|
||||
opentelemetry-instrumentation-sqlalchemy==0.42b0
|
||||
|
||||
# Data Validation & Transformation
|
||||
marshmallow==3.20.1
|
||||
|
||||
# Testing
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
httpx==0.25.2
|
||||
|
||||
# Linting & Type Checking
|
||||
ruff==0.1.8
|
||||
mypy==1.7.1
|
||||
types-python-dateutil==2.8.20
|
||||
|
||||
# Development
|
||||
black==23.12.0
|
||||
isort==5.13.2
|
||||
pre-commit==3.5.0
|
||||
|
||||
# CORS & Middleware
|
||||
fastapi-cors==0.0.6
|
||||
|
||||
# Rate Limiting
|
||||
slowapi==0.1.9
|
||||
|
||||
# Background Tasks
|
||||
celery==5.3.4
|
||||
redis==5.0.1
|
||||
Loading…
Add table
Reference in a new issue