import datetime as dt import os from functools import lru_cache from typing import Any, Literal, Optional from dotenv import load_dotenv from fastapi import Depends, FastAPI, Header, HTTPException, Query from pydantic import BaseModel, Field from supabase import Client, create_client load_dotenv() app = FastAPI(title="NutritionBot API") MenuMealType = Literal["breakfast", "lunch", "dinner"] MealLogMealType = Literal["breakfast", "lunch", "dinner", "snack"] MealLogStatus = Literal["before_only", "completed"] class HealthResponse(BaseModel): status: str class MenuItem(BaseModel): name: str calories: Optional[int] = None protein_g: Optional[float] = None carbs_g: Optional[float] = None fat_g: Optional[float] = None class FoodItem(BaseModel): name: str estimated_served_g: Optional[float] = None estimated_consumed_g: Optional[float] = None consumed_ratio: Optional[float] = None calories: Optional[int] = None protein_g: Optional[float] = None carbs_g: Optional[float] = None fat_g: Optional[float] = None class SaveMenuRequest(BaseModel): user_id: str date: dt.date meal_type: MenuMealType menu_items: list[MenuItem] source: str = "photo_ocr" class MenuRecord(BaseModel): id: str created_at: dt.datetime user_id: str date: dt.date meal_type: MenuMealType menu_items: list[MenuItem] source: Optional[str] = None class SaveMenuResponse(BaseModel): status: str menu_id: str class TodayMenuResponse(BaseModel): menus: list[MenuRecord] class SaveMealRequest(BaseModel): user_id: str date: dt.date = Field(default_factory=dt.date.today) meal_type: MealLogMealType menu_id: Optional[str] = None status: MealLogStatus = "before_only" food_items: list[FoodItem] total_calories: int total_protein_g: float total_carbs_g: float total_fat_g: float confidence_score: float = Field(default=0.0, ge=0.0, le=1.0) analysis_note: Optional[str] = None class UpdateMealRequest(BaseModel): user_id: str date: Optional[dt.date] = None meal_type: Optional[MealLogMealType] = None menu_id: Optional[str] = None status: Optional[MealLogStatus] = None food_items: Optional[list[FoodItem]] = None total_calories: Optional[int] = None total_protein_g: Optional[float] = None total_carbs_g: Optional[float] = None total_fat_g: Optional[float] = None confidence_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) analysis_note: Optional[str] = None class MealLogRecord(BaseModel): id: str created_at: dt.datetime user_id: str date: dt.date meal_type: MealLogMealType menu_id: Optional[str] = None status: MealLogStatus food_items: list[FoodItem] total_calories: int total_protein_g: float total_carbs_g: float total_fat_g: float confidence_score: Optional[float] = 0.0 analysis_note: Optional[str] = None class SaveMealResponse(BaseModel): status: str meal_id: str class UpdateMealResponse(BaseModel): status: str meal_id: str class MealHistoryResponse(BaseModel): meals: list[MealLogRecord] count: int def get_required_env(name: str) -> str: value = os.environ.get(name) if not value: raise RuntimeError(f"Missing required environment variable: {name}") return value @lru_cache def get_supabase_client() -> Client: return create_client( get_required_env("SUPABASE_URL"), get_required_env("SUPABASE_KEY"), ) def get_client() -> Client: try: return get_supabase_client() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e def verify_api_key(x_api_key: str = Header(..., alias="X-Api-Key")) -> None: expected_api_key = os.environ.get("API_KEY") if not expected_api_key or x_api_key != expected_api_key: raise HTTPException(status_code=401, detail="Unauthorized") def execute_supabase(query: Any) -> Any: try: response = query.execute() if response is not None: error = getattr(response, "error", None) if error: raise HTTPException(status_code=500, detail=str(error)) return response except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e def get_first_row_or_500(response: Any, entity_name: str) -> dict[str, Any]: rows = getattr(response, "data", None) or [] if not rows: raise HTTPException( status_code=500, detail=f"No {entity_name} returned from Supabase.", ) return rows[0] @app.get("/", response_model=HealthResponse) def healthcheck() -> HealthResponse: return HealthResponse(status="ok") @app.post("/save-menu", response_model=SaveMenuResponse) def save_menu( payload: SaveMenuRequest, _: None = Depends(verify_api_key), ) -> SaveMenuResponse: supabase = get_client() response = execute_supabase( supabase.table("school_menus").upsert( payload.model_dump(mode="json"), on_conflict="user_id,date,meal_type", ) ) rows = getattr(response, "data", None) or [] if rows: return SaveMenuResponse(status="saved", menu_id=str(rows[0]["id"])) lookup = execute_supabase( supabase.table("school_menus") .select("id") .eq("user_id", payload.user_id) .eq("date", payload.date.isoformat()) .eq("meal_type", payload.meal_type) .maybe_single() ) if lookup is None or lookup.data is None: raise HTTPException(status_code=500, detail="Menu was saved but no row was returned.") return SaveMenuResponse(status="saved", menu_id=str(lookup.data["id"])) @app.get("/today-menu", response_model=TodayMenuResponse) def get_today_menu( user_id: str = Query(...), request_date: Optional[dt.date] = Query(default=None, alias="date"), meal_type: Optional[MenuMealType] = Query(default=None), _: None = Depends(verify_api_key), ) -> TodayMenuResponse: supabase = get_client() target_date = request_date or dt.date.today() query = ( supabase.table("school_menus") .select("*") .eq("user_id", user_id) .eq("date", target_date.isoformat()) ) if meal_type: query = query.eq("meal_type", meal_type) response = execute_supabase(query.order("meal_type")) return TodayMenuResponse(menus=getattr(response, "data", None) or []) @app.post("/save-meal", response_model=SaveMealResponse) def save_meal( payload: SaveMealRequest, _: None = Depends(verify_api_key), ) -> SaveMealResponse: supabase = get_client() response = execute_supabase( supabase.table("meal_logs").insert(payload.model_dump(mode="json")) ) meal_row = get_first_row_or_500(response, "meal log") return SaveMealResponse(status="saved", meal_id=str(meal_row["id"])) @app.patch("/update-meal/{meal_id}", response_model=UpdateMealResponse) def update_meal( meal_id: str, payload: UpdateMealRequest, _: None = Depends(verify_api_key), ) -> UpdateMealResponse: supabase = get_client() existing_response = execute_supabase( supabase.table("meal_logs").select("*").eq("id", meal_id).maybe_single() ) if existing_response is None or existing_response.data is None: raise HTTPException(status_code=404, detail="Meal log not found") existing_meal = existing_response.data if existing_meal.get("user_id") != payload.user_id: raise HTTPException(status_code=403, detail="Forbidden") update_data = payload.model_dump( mode="json", exclude={"user_id"}, exclude_unset=True, ) if not update_data: return UpdateMealResponse(status="updated", meal_id=meal_id) update_response = execute_supabase( supabase.table("meal_logs") .update(update_data) .eq("id", meal_id) .eq("user_id", payload.user_id) ) get_first_row_or_500(update_response, "updated meal log") return UpdateMealResponse(status="updated", meal_id=meal_id) @app.get("/meal-history", response_model=MealHistoryResponse) def get_meal_history( user_id: str = Query(...), days: int = Query(default=7, ge=0), limit: int = Query(default=20, ge=1, le=50), _: None = Depends(verify_api_key), ) -> MealHistoryResponse: supabase = get_client() since_date = dt.date.today() - dt.timedelta(days=days) response = execute_supabase( supabase.table("meal_logs") .select("*") .eq("user_id", user_id) .gte("date", since_date.isoformat()) .order("created_at", desc=True) .limit(limit) ) meals = getattr(response, "data", None) or [] return MealHistoryResponse(meals=meals, count=len(meals))