Deliver Steps 3-8: integration config, meal plans, provider registry, MCP connector (14 tools), B2A files, nginx proxy, AGENT_GUIDE, LICENSE

This commit is contained in:
Claude 2026-06-15 14:34:09 +00:00
parent cb892564d9
commit 6fb5d759ef
13 changed files with 958 additions and 2 deletions

View file

@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
logger.info("Fitness Rewards backend shutting down")
app = FastAPI(title="Fitness Rewards", version="0.3.0", lifespan=lifespan)
app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@ -84,6 +84,7 @@ from app.routers.programs import router as programs_router
from app.routers.catalog import router as catalog_router
from app.routers.support import router as support_router
from app.routers.nutrition import router as nutrition_router
from app.routers.meal_plans import router as meal_plans_router
app.include_router(auth_router)
app.include_router(onboarding_router)
@ -96,11 +97,12 @@ app.include_router(catalog_router)
app.include_router(support_router)
app.include_router(programs_router)
app.include_router(nutrition_router)
app.include_router(meal_plans_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "0.3.0"}
return {"status": "ok", "version": "1.0.0"}

View file

@ -10,6 +10,8 @@ from app.models.resource import Resource
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
from app.models.support import SupportThread, SupportMessage
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
from app.models.integration_config import IntegrationConfig
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
__all__ = [
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
@ -18,4 +20,5 @@ __all__ = [
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
"SupportThread", "SupportMessage",
"NutritionGoal", "Fast", "ElectrolyteLog",
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
]

View file

@ -0,0 +1,25 @@
"""Integration configuration model — encrypted credential storage."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class IntegrationConfig(Base):
__tablename__ = "integration_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
config_key: Mapped[str] = mapped_column(String(100), nullable=False)
config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint("user_id", "provider", "config_key", name="uq_integration_config"),
)

View file

@ -0,0 +1,65 @@
"""Meal plan models — plans, items, and compliance tracking."""
from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from decimal import Decimal
from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class MealPlan(Base):
__tablename__ = "meal_plans"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
restrictions: Mapped[dict] = mapped_column(JSONB, default=list)
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
items: Mapped[list["MealPlanItem"]] = relationship(back_populates="plan", cascade="all, delete-orphan", lazy="selectin")
class MealPlanItem(Base):
__tablename__ = "meal_plan_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False)
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
protein_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
carbs_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
fat_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
fiber_g: Mapped[Decimal | None] = mapped_column(DECIMAL(6, 1), nullable=True)
serving_size: Mapped[str | None] = mapped_column(String(100), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
plan: Mapped["MealPlan"] = relationship(back_populates="items")
class MealCompliance(Base):
__tablename__ = "meal_compliance"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False)
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
compliance_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped
substitution: Mapped[str | None] = mapped_column(Text, nullable=True)
food_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,295 @@
"""Meal plans router — CRUD, compliance tracking, progress."""
from __future__ import annotations
import uuid as uuid_mod
from datetime import date, datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
# ── Schemas ──────────────────────────────────────────────────────────────
class MealItemCreate(BaseModel):
day_number: int
meal_type: str
food_name: str
description: str | None = None
calories: int | None = None
protein_g: float | None = None
carbs_g: float | None = None
fat_g: float | None = None
fiber_g: float | None = None
serving_size: str | None = None
sort_order: int = 0
class MealPlanCreate(BaseModel):
name: str
diet_type: str | None = None
daily_calories: int | None = None
daily_protein_g: int | None = None
daily_carbs_g: int | None = None
daily_fat_g: int | None = None
restrictions: list[str] = []
duration_days: int
start_date: date | None = None
meals: list[MealItemCreate] = []
class ComplianceCreate(BaseModel):
plan_item_id: str | None = None
compliance_date: date | None = None
status: str # followed, substituted, skipped
substitution: str | None = None
# ── Endpoints ────────────────────────────────────────────────────────────
@router.get("")
async def list_meal_plans(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(MealPlan)
.where(MealPlan.user_id == user.id)
.order_by(MealPlan.created_at.desc())
)
plans = result.scalars().all()
return [_plan_summary(p) for p in plans]
@router.post("", status_code=201)
async def create_meal_plan(
body: MealPlanCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
# Deactivate any existing active plan
result = await db.execute(
select(MealPlan)
.where(MealPlan.user_id == user.id, MealPlan.status == "active")
)
for old in result.scalars().all():
old.status = "completed"
plan = MealPlan(
user_id=user.id,
name=body.name,
diet_type=body.diet_type,
daily_calories=body.daily_calories,
daily_protein_g=body.daily_protein_g,
daily_carbs_g=body.daily_carbs_g,
daily_fat_g=body.daily_fat_g,
restrictions=body.restrictions,
duration_days=body.duration_days,
start_date=body.start_date or date.today(),
)
db.add(plan)
await db.flush()
for item in body.meals:
db.add(MealPlanItem(
plan_id=plan.id,
day_number=item.day_number,
meal_type=item.meal_type,
food_name=item.food_name,
description=item.description,
calories=item.calories,
protein_g=item.protein_g,
carbs_g=item.carbs_g,
fat_g=item.fat_g,
fiber_g=item.fiber_g,
serving_size=item.serving_size,
sort_order=item.sort_order,
))
return {"id": str(plan.id), "name": plan.name, "items_count": len(body.meals)}
@router.get("/today")
async def get_today_meals(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(MealPlan)
.where(MealPlan.user_id == user.id, MealPlan.status == "active")
)
plan = result.scalar_one_or_none()
if not plan:
return {"active_plan": None}
day_num = (date.today() - plan.start_date).days + 1
if day_num < 1 or day_num > plan.duration_days:
return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"}
items_result = await db.execute(
select(MealPlanItem)
.where(MealPlanItem.plan_id == plan.id, MealPlanItem.day_number == day_num)
.order_by(MealPlanItem.sort_order)
)
items = items_result.scalars().all()
return {
"active_plan": plan.name,
"diet_type": plan.diet_type,
"day": day_num,
"duration_days": plan.duration_days,
"daily_calories": plan.daily_calories,
"meals": [
{
"id": str(i.id),
"meal_type": i.meal_type,
"food_name": i.food_name,
"description": i.description,
"calories": i.calories,
"protein_g": float(i.protein_g) if i.protein_g else None,
"carbs_g": float(i.carbs_g) if i.carbs_g else None,
"fat_g": float(i.fat_g) if i.fat_g else None,
}
for i in items
],
}
@router.get("/{plan_id}")
async def get_meal_plan(
plan_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(MealPlan)
.where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(404, "Plan not found")
return {**_plan_summary(plan), "items": [_item_detail(i) for i in plan.items]}
@router.patch("/{plan_id}")
async def update_meal_plan_status(
plan_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
status: str = "completed",
):
result = await db.execute(
select(MealPlan)
.where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(404, "Plan not found")
plan.status = status
return {"id": str(plan.id), "status": plan.status}
@router.post("/compliance", status_code=201)
async def log_compliance(
body: ComplianceCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
# Find active plan
result = await db.execute(
select(MealPlan).where(MealPlan.user_id == user.id, MealPlan.status == "active")
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(400, "No active meal plan")
entry = MealCompliance(
user_id=user.id,
plan_id=plan.id,
plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None,
compliance_date=body.compliance_date or date.today(),
status=body.status,
substitution=body.substitution,
)
db.add(entry)
return {"status": body.status, "date": str(entry.compliance_date)}
@router.get("/{plan_id}/progress")
async def get_plan_progress(
plan_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(MealPlan)
.where(MealPlan.id == uuid_mod.UUID(plan_id), MealPlan.user_id == user.id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(404, "Plan not found")
comp_result = await db.execute(
select(MealCompliance)
.where(MealCompliance.plan_id == plan.id, MealCompliance.user_id == user.id)
)
entries = comp_result.scalars().all()
total = len(entries)
followed = sum(1 for e in entries if e.status == "followed")
substituted = sum(1 for e in entries if e.status == "substituted")
skipped = sum(1 for e in entries if e.status == "skipped")
days_elapsed = (date.today() - plan.start_date).days + 1
compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0
return {
"plan": plan.name,
"days_elapsed": min(days_elapsed, plan.duration_days),
"duration_days": plan.duration_days,
"total_entries": total,
"followed": followed,
"substituted": substituted,
"skipped": skipped,
"compliance_pct": round(compliance_pct, 1),
}
# ── Helpers ──────────────────────────────────────────────────────────────
def _plan_summary(p: MealPlan) -> dict:
return {
"id": str(p.id),
"name": p.name,
"diet_type": p.diet_type,
"daily_calories": p.daily_calories,
"duration_days": p.duration_days,
"start_date": str(p.start_date),
"status": p.status,
}
def _item_detail(i: MealPlanItem) -> dict:
return {
"id": str(i.id),
"day_number": i.day_number,
"meal_type": i.meal_type,
"food_name": i.food_name,
"description": i.description,
"calories": i.calories,
"protein_g": float(i.protein_g) if i.protein_g else None,
"carbs_g": float(i.carbs_g) if i.carbs_g else None,
"fat_g": float(i.fat_g) if i.fat_g else None,
"fiber_g": float(i.fiber_g) if i.fiber_g else None,
"serving_size": i.serving_size,
}

View file

@ -0,0 +1,89 @@
"""Provider registry — drives integration UI cards and agent help text."""
from __future__ import annotations
PROVIDER_REGISTRY: dict[str, dict] = {
"strava": {
"name": "Strava",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"auth_url": "https://www.strava.com/oauth/authorize",
"token_url": "https://www.strava.com/oauth/token",
"scopes": "read,activity:read_all",
"help_url": "https://developers.strava.com/",
"help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback",
},
"polar": {
"name": "Polar",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"auth_url": "https://flow.polar.com/oauth2/authorization",
"token_url": "https://polarremote.com/v2/oauth2/token",
"help_url": "https://admin.polaraccesslink.com/",
"help_text": "Register at admin.polaraccesslink.com",
},
"garmin": {
"name": "Garmin Connect",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"help_url": "https://developer.garmin.com/gc-developer-program/",
"help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
},
"fitbit": {
"name": "Fitbit",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"auth_url": "https://www.fitbit.com/oauth2/authorize",
"token_url": "https://api.fitbit.com/oauth2/token",
"help_url": "https://dev.fitbit.com/",
"help_text": "Register at dev.fitbit.com",
},
"withings": {
"name": "Withings",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"help_url": "https://developer.withings.com/",
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
},
"whoop": {
"name": "WHOOP",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"help_url": "https://developer.whoop.com/",
"help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
},
"oura": {
"name": "Oura Ring",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"help_url": "https://cloud.ouraring.com/v2/docs",
"help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
},
"usda": {
"name": "USDA FoodData Central",
"type": "api_key",
"fields": ["api_key"],
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
},
"nutritionix": {
"name": "Nutritionix",
"type": "api_key",
"fields": ["app_id", "api_key"],
"help_url": "https://developer.nutritionix.com/",
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
},
"groq": {
"name": "Groq (Program Research)",
"type": "api_key",
"fields": ["api_key"],
"help_url": "https://console.groq.com/keys",
"help_text": "Free API key. Enables AI-powered workout program extraction.",
},
"telegram": {
"name": "Telegram Notifications",
"type": "api_key",
"fields": ["bot_token", "chat_id"],
"help_url": "https://core.telegram.org/bots#botfather",
"help_text": "Create a bot via @BotFather, then get your chat ID",
},
}