from datetime import datetime, timezone import secrets import uuid from fastapi import Depends, FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy import select from sqlalchemy.orm import Session from .config import get_settings from .db import Base, engine, get_db from .models import Entry, Profile, User from .schemas import CoachChatIn, CoachChatOut, EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token from .coach import chat_with_peg, peg_provider_health from .security import current_user, hash_password, make_token, verify_password cfg = get_settings() if cfg.environment == "production" and (len(cfg.jwt_secret) < 32 or cfg.jwt_secret.startswith("change-me")): raise RuntimeError("Set a strong JWT_SECRET in production") app = FastAPI(title="MetaBloom API", version="1.0.0", description="Wellness tracking API — not a medical device.") app.add_middleware(CORSMiddleware, allow_origins=cfg.origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) @app.on_event("startup") def startup(): Base.metadata.create_all(engine) @app.get("/health") def health(): return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."} @app.get("/health/ai") async def ai_health(): return await peg_provider_health(cfg) @app.post("/coach/chat", response_model=CoachChatOut) async def coach_chat(data: CoachChatIn, user: User = Depends(current_user)): return await chat_with_peg(data, cfg) @app.post("/auth/register", response_model=Token, status_code=201) def register(data: Register, db: Session = Depends(get_db)): email = data.email.lower() if db.scalar(select(User).where(User.email == email)): raise HTTPException(409, "Email already registered") user = User(email=email, password_hash=hash_password(data.password)) db.add(user); db.flush() db.add(Profile(user_id=user.id)); db.commit() return Token(access_token=make_token(user.id)) @app.post("/auth/guest", response_model=Token, status_code=201) def guest_session(db: Session = Depends(get_db)): """Create a private pseudonymous session for a first app launch.""" user = User( email=f"guest-{uuid.uuid4()}@local.invalid", password_hash=hash_password(secrets.token_urlsafe(32)), ) db.add(user) db.flush() db.add(Profile(user_id=user.id)) db.commit() return Token(access_token=make_token(user.id)) @app.post("/auth/token", response_model=Token) def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): user = db.scalar(select(User).where(User.email == form.username.lower())) if not user or not verify_password(form.password, user.password_hash): raise HTTPException(401, "Invalid credentials", headers={"WWW-Authenticate": "Bearer"}) return Token(access_token=make_token(user.id)) @app.get("/profile", response_model=ProfileOut) def get_profile(user: User = Depends(current_user)): return user.profile @app.put("/profile", response_model=ProfileOut) def put_profile(data: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)): profile = user.profile or Profile(user_id=user.id) for key, value in data.model_dump().items(): setattr(profile, key, value) db.add(profile); db.commit(); db.refresh(profile) return profile @app.get("/entries", response_model=list[EntryOut]) def list_entries(kind: str | None = None, limit: int = 100, user: User = Depends(current_user), db: Session = Depends(get_db)): limit = min(max(limit, 1), 500) query = select(Entry).where(Entry.user_id == user.id) if kind: query = query.where(Entry.kind == kind) return db.scalars(query.order_by(Entry.recorded_at.desc()).limit(limit)).all() @app.post("/entries", response_model=EntryOut, status_code=201) def create_entry(data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)): if not user.profile or not user.profile.consent_health_data: raise HTTPException(403, "Health-data consent is required") entry = Entry(user_id=user.id, **data.model_dump(exclude_none=True)) db.add(entry); db.commit(); db.refresh(entry) return entry def owned_entry(entry_id: str, user: User, db: Session) -> Entry: entry = db.scalar(select(Entry).where(Entry.id == entry_id, Entry.user_id == user.id)) if not entry: raise HTTPException(404, "Entry not found") return entry @app.put("/entries/{entry_id}", response_model=EntryOut) def update_entry(entry_id: str, data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)): entry = owned_entry(entry_id, user, db) for key, value in data.model_dump(exclude_none=True).items(): setattr(entry, key, value) db.commit(); db.refresh(entry) return entry @app.delete("/entries/{entry_id}", status_code=204) def delete_entry(entry_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)): db.delete(owned_entry(entry_id, user, db)); db.commit() return Response(status_code=204) @app.get("/account/export") def export_account(user: User = Depends(current_user)): return {"exported_at": datetime.now(timezone.utc), "medical_notice": "Informational data only; consult a professional for medical interpretation.", "profile": ProfileOut.model_validate(user.profile), "entries": [EntryOut.model_validate(x) for x in user.entries]} @app.delete("/account", status_code=204) def delete_account(user: User = Depends(current_user), db: Session = Depends(get_db)): db.delete(user); db.commit() return Response(status_code=204)