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:
parent
cb892564d9
commit
6fb5d759ef
13 changed files with 958 additions and 2 deletions
|
|
@ -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"}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
25
backend/app/models/integration_config.py
Normal file
25
backend/app/models/integration_config.py
Normal 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"),
|
||||
)
|
||||
65
backend/app/models/meal_plan.py
Normal file
65
backend/app/models/meal_plan.py
Normal 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))
|
||||
295
backend/app/routers/meal_plans.py
Normal file
295
backend/app/routers/meal_plans.py
Normal 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,
|
||||
}
|
||||
89
backend/app/services/provider_registry.py
Normal file
89
backend/app/services/provider_registry.py
Normal 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",
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue