diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..a34ed7c --- /dev/null +++ b/backend/.gitignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ffcb568 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..4868b64 --- /dev/null +++ b/backend/README.md @@ -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= +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 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..0c9c42d --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -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"] diff --git a/backend/app/api/v1/blog.py b/backend/app/api/v1/blog.py new file mode 100644 index 0000000..fad9480 --- /dev/null +++ b/backend/app/api/v1/blog.py @@ -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"} diff --git a/backend/app/api/v1/forum.py b/backend/app/api/v1/forum.py new file mode 100644 index 0000000..81ed3dd --- /dev/null +++ b/backend/app/api/v1/forum.py @@ -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"} diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..2b32503 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -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"} diff --git a/backend/app/api/v1/merch.py b/backend/app/api/v1/merch.py new file mode 100644 index 0000000..91b3eb0 --- /dev/null +++ b/backend/app/api/v1/merch.py @@ -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"} diff --git a/backend/app/api/v1/podcast.py b/backend/app/api/v1/podcast.py new file mode 100644 index 0000000..2ef2cf4 --- /dev/null +++ b/backend/app/api/v1/podcast.py @@ -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"} diff --git a/backend/app/api/v1/profiles.py b/backend/app/api/v1/profiles.py new file mode 100644 index 0000000..0d1e7fd --- /dev/null +++ b/backend/app/api/v1/profiles.py @@ -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} diff --git a/backend/app/api/v1/resources.py b/backend/app/api/v1/resources.py new file mode 100644 index 0000000..dd07342 --- /dev/null +++ b/backend/app/api/v1/resources.py @@ -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"} diff --git a/backend/app/api/v1/tribute.py b/backend/app/api/v1/tribute.py new file mode 100644 index 0000000..1ee8624 --- /dev/null +++ b/backend/app/api/v1/tribute.py @@ -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"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a2397f4 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..671d982 --- /dev/null +++ b/backend/app/database.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..cf3e838 --- /dev/null +++ b/backend/app/main.py @@ -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(), + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..514546f --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/authentication.py b/backend/app/models/authentication.py new file mode 100644 index 0000000..7055c7d --- /dev/null +++ b/backend/app/models/authentication.py @@ -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") diff --git a/backend/app/models/blog.py b/backend/app/models/blog.py new file mode 100644 index 0000000..974792b --- /dev/null +++ b/backend/app/models/blog.py @@ -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") diff --git a/backend/app/models/forum.py b/backend/app/models/forum.py new file mode 100644 index 0000000..ab7ef9b --- /dev/null +++ b/backend/app/models/forum.py @@ -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") diff --git a/backend/app/models/merch.py b/backend/app/models/merch.py new file mode 100644 index 0000000..f04ff32 --- /dev/null +++ b/backend/app/models/merch.py @@ -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") diff --git a/backend/app/models/podcast.py b/backend/app/models/podcast.py new file mode 100644 index 0000000..389d1fe --- /dev/null +++ b/backend/app/models/podcast.py @@ -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()) diff --git a/backend/app/models/resources.py b/backend/app/models/resources.py new file mode 100644 index 0000000..7426cd8 --- /dev/null +++ b/backend/app/models/resources.py @@ -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") diff --git a/backend/app/models/tribute.py b/backend/app/models/tribute.py new file mode 100644 index 0000000..8a2f853 --- /dev/null +++ b/backend/app/models/tribute.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..61a2297 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..44d9efd --- /dev/null +++ b/backend/docker-compose.yml @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..933620b --- /dev/null +++ b/backend/requirements.txt @@ -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