v2.0: dual deployment — pip install (SQLite) + Docker (PostgreSQL)

Architecture:
- Moved backend/app/ to diligence/ Python package
- Dialect-agnostic models (Uuid + JSON, no PostgreSQL imports)
- Auto-detect database: empty DATABASE_URL → SQLite at ~/.diligence/data.db
- Cross-dialect migrations (inspector-based, no raw PostgreSQL DDL)
- FastAPI serves pre-built React frontend for pip path
- CLI entry point: diligence [--port] [--no-browser] [--data-dir]
- pyproject.toml with optional deps: [postgres], [mcp], [dev]
- Docker path unchanged: docker compose up -d

pip install path: pip install . && diligence
Docker path: ./setup.sh && docker compose up -d
This commit is contained in:
Claude 2026-07-01 03:30:12 +00:00
parent 0ce1463de8
commit a6e6e2f54f
77 changed files with 1537 additions and 1007 deletions

View file

@ -1,7 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
COPY diligence/ ./diligence/
CMD ["uvicorn", "diligence.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,45 +0,0 @@
from __future__ import annotations
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database - hostname 'fitness-db' avoids collision with other 'db' containers on Coolify network
database_url: str = "postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards"
# Auth
secret_key: str = "change-me-in-production"
crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
access_token_expire_minutes: int = 1440 # 24 hours
api_token: str = "" # MCP connector auth — generated by setup.sh
# Strava
strava_client_id: str = ""
strava_client_secret: str = ""
# Polar
polar_client_id: str = ""
polar_client_secret: str = ""
# Groq (program extraction)
groq_api_key: str = ""
# Telegram (support notifications — outbound only)
telegram_bot_token: str = ""
telegram_chat_id: str = ""
# App
base_url: str = "http://localhost"
timezone: str = "Asia/Bangkok"
model_config = {"env_file": ".env", "extra": "ignore"}
@lru_cache
def get_settings() -> Settings:
return Settings()
# Module-level shortcut used by services
settings = get_settings()

View file

@ -1,34 +0,0 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False, pool_size=5, max_overflow=10)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
# Import all models so their tables are registered on Base.metadata
import app.models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View file

@ -1,428 +0,0 @@
from __future__ import annotations
import asyncio
import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("fitness-rewards")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Retry DB init — container may start before postgres is fully accepting connections
for attempt in range(10):
try:
from app.database import init_db
await init_db()
logger.info("Database tables created successfully")
break
except Exception as e:
logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}")
if attempt < 9:
await asyncio.sleep(3)
else:
logger.error("Could not initialize database after 10 attempts — starting without tables")
# SEC-05: Fail fast if SECRET_KEY not configured
from app.config import get_settings
_s = get_settings()
if _s.secret_key == "change-me-in-production":
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
sys.exit(1)
# Run lightweight migrations for schema changes
try:
await run_migrations()
logger.info("Migrations completed")
except Exception as e:
logger.warning(f"Migration failed (non-fatal): {e}")
# Seed resources (non-fatal if it fails)
try:
await seed_resources()
logger.info("Resource library seeded")
except Exception as e:
logger.warning(f"Resource seeding failed (non-fatal): {e}")
# Start background crawl queue scheduler (gated on CRAWL_ENABLED)
crawl_task = None
if _s.crawl_enabled:
try:
from app.services.crawl_scheduler import crawl_queue_loop
crawl_task = asyncio.create_task(crawl_queue_loop())
logger.info("Crawl queue scheduler started")
except Exception as e:
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
else:
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
logger.info("Fitness Rewards backend started")
yield
# Shutdown
if crawl_task:
crawl_task.cancel()
try:
await crawl_task
except asyncio.CancelledError:
pass
logger.info("Fitness Rewards backend shutting down")
app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False, # Bearer tokens don't need credentials mode
allow_methods=["*"],
allow_headers=["*"],
)
# Import routers after app creation to avoid circular imports
from app.routers.auth import router as auth_router
from app.routers.onboarding import router as onboarding_router
from app.routers.activities import router as activities_router
from app.routers.food import router as food_router
from app.routers.points import router as points_router, rewards_router
from app.routers.integrations import router as integrations_router
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
from app.routers.ai_chat import router as ai_chat_router
app.include_router(auth_router)
app.include_router(onboarding_router)
app.include_router(activities_router)
app.include_router(food_router)
app.include_router(points_router)
app.include_router(rewards_router)
app.include_router(integrations_router)
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.include_router(ai_chat_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "1.0.0"}
async def run_migrations():
"""Add missing columns to existing tables (lightweight schema migration)."""
from app.database import engine
from sqlalchemy import text
async with engine.begin() as conn:
# v1: Add equipment_list JSONB column if missing
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS equipment_list JSONB DEFAULT '[]';
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v2: Add catalog columns to programs table
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE programs ADD COLUMN IF NOT EXISTS catalog_id UUID REFERENCES program_catalog(id);
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_week INTEGER DEFAULT 1;
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_day INTEGER DEFAULT 1;
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v2.1: Add week_number to workout_logs for template rotation tracking
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE workout_logs ADD COLUMN IF NOT EXISTS week_number INTEGER NOT NULL DEFAULT 1;
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
# NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs,
# meal plans) are added after this block.
await conn.execute(text("""
DO $$ BEGIN
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'fast_completed', 200, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'fast_completed'
);
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'keto_compliant_day', 100, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'keto_compliant_day'
);
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v4: Add is_admin column to users
await conn.execute(text("""
DO $$ BEGIN
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
# v4.1: Grant admin to first registered user if no admin exists
await conn.execute(text("""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN
UPDATE users SET is_admin = TRUE
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
END IF;
EXCEPTION WHEN undefined_table THEN NULL;
WHEN undefined_column THEN NULL;
END $$;
"""))
# v5: Create integration_configs table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS integration_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
provider VARCHAR(50) NOT NULL,
config_key VARCHAR(100) NOT NULL,
config_value TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, provider, config_key)
);
"""))
# v6: Create meal plan tables
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS meal_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
name VARCHAR(200) NOT NULL,
diet_type VARCHAR(50),
daily_calories INTEGER,
daily_protein_g INTEGER,
daily_carbs_g INTEGER,
daily_fat_g INTEGER,
restrictions JSONB DEFAULT '[]',
duration_days INTEGER NOT NULL,
start_date DATE NOT NULL,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW()
);
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS meal_plan_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
meal_type VARCHAR(20) NOT NULL,
food_name VARCHAR(300) NOT NULL,
description TEXT,
calories INTEGER,
protein_g DECIMAL(6,1),
carbs_g DECIMAL(6,1),
fat_g DECIMAL(6,1),
fiber_g DECIMAL(6,1),
serving_size VARCHAR(100),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS meal_compliance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
plan_id UUID NOT NULL REFERENCES meal_plans(id),
plan_item_id UUID,
compliance_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
substitution TEXT,
food_log_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number);
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date);
"""))
# v7: Seed meal plan point rules
await conn.execute(text("""
DO $$ BEGIN
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed'
);
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM point_rules pr
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial'
);
EXCEPTION WHEN undefined_table THEN NULL;
END $$;
"""))
async def seed_resources():
"""Seed the resource library with curated fitness programs."""
from app.database import async_session
from app.models.resource import Resource
from sqlalchemy import select
async with async_session() as db:
existing = await db.execute(select(Resource).limit(1))
if existing.scalar_one_or_none():
return # Already seeded
resources = [
Resource(
name="Darebee Foundation Program",
source="darebee",
url="https://darebee.com/programs/foundation-program.html",
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
goal_tags=["get_active", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee 30 Days of Change",
source="darebee",
url="https://darebee.com/programs/30-days-of-change.html",
description="30-day progressive program for building fitness habits. Bodyweight only.",
goal_tags=["get_active", "lose_weight", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee IRONHEART",
source="darebee",
url="https://darebee.com/programs/ironheart.html",
description="Strength-focused bodyweight program. No equipment, all levels.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee Total Body Strength",
source="darebee",
url="https://darebee.com/programs/total-body-strength.html",
description="Comprehensive strength building program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee 8 Weeks to 5K",
source="darebee",
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
description="Progressive running program from beginner to 5K in 8 weeks.",
goal_tags=["get_active", "lose_weight"],
activity_tags=["running"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=56,
),
Resource(
name="StrongLifts 5x5",
source="stronglifts",
url="https://stronglifts.com/5x5/",
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
goal_tags=["build_strength"],
activity_tags=["weights"],
equipment_needed="full_gym",
ttm_stages=["preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=90,
),
Resource(
name="Darebee 30 Days of Cardio",
source="darebee",
url="https://darebee.com/programs/30-days-of-cardio.html",
description="30-day cardio program, no equipment. Great for weight loss.",
goal_tags=["lose_weight", "get_active"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee POWERBUILDER",
source="darebee",
url="https://darebee.com/programs/powerbuilder-program.html",
description="Advanced strength and power program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="advanced",
duration_days=30,
),
Resource(
name="Fitness Blender (YouTube)",
source="youtube",
url="https://www.youtube.com/@fitnessblender",
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
activity_tags=["bodyweight", "yoga"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=None,
),
Resource(
name="Darebee Yoga Flexibility Program",
source="darebee",
url="https://darebee.com/programs/flexibility-program.html",
description="30-day flexibility and yoga program for beginners.",
goal_tags=["feel_better"],
activity_tags=["yoga"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
]
for r in resources:
db.add(r)
await db.commit()

View file

@ -1,24 +0,0 @@
from app.models.user import User
from app.models.profile import UserProfile
from app.models.program import Program
from app.models.activity import ActivityLog
from app.models.food import FoodLog
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward, RewardRedemption
from app.models.oauth import OAuthToken
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",
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
"OAuthToken", "Resource",
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
"SupportThread", "SupportMessage",
"NutritionGoal", "Fast", "ElectrolyteLog",
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
]

View file

@ -1,33 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ActivityLog(Base):
__tablename__ = "activity_log"
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), ForeignKey("users.id"), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str | None] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(Text)
duration_minutes: Mapped[int | None] = mapped_column(Integer)
source: Mapped[str] = mapped_column(String(30), default="manual")
external_id: Mapped[str | None] = mapped_column(String(100))
points_earned: Mapped[int] = mapped_column(Integer, default=0)
rule_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("point_rules.id"), nullable=True)
activity_date: Mapped[date] = mapped_column(Date, nullable=False)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
program_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("programs.id"), nullable=True)
program_day: Mapped[int | None] = mapped_column(Integer)
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
__table_args__ = (
Index("idx_activity_log_user_date", "user_id", "activity_date"),
Index("idx_activity_log_external", "source", "external_id"),
)

View file

@ -1,103 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ProgramCatalog(Base):
"""Shared program definitions — one per program, reusable by all users."""
__tablename__ = "program_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
source_url: Mapped[str | None] = mapped_column(Text)
duration_weeks: Mapped[int | None] = mapped_column(Integer)
frequency_per_week: Mapped[int | None] = mapped_column(Integer)
equipment: Mapped[dict] = mapped_column(JSONB, default=list)
difficulty: Mapped[str | None] = mapped_column(String(20))
category: Mapped[str | None] = mapped_column(String(50))
progression_rules: Mapped[str | None] = mapped_column(Text)
structured_data: Mapped[dict | None] = mapped_column(JSONB)
crawl_status: Mapped[str] = mapped_column(String(20), default="pending")
crawl_error: Mapped[str | None] = mapped_column(Text)
crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
workouts: Mapped[list["CatalogWorkout"]] = relationship(
back_populates="catalog", cascade="all, delete-orphan",
order_by="CatalogWorkout.week_number, CatalogWorkout.day_number"
)
class CatalogWorkout(Base):
"""Individual workout within a catalog program."""
__tablename__ = "catalog_workouts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
catalog_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False
)
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
workout_name: Mapped[str | None] = mapped_column(String(200))
exercises: Mapped[dict] = mapped_column(JSONB, nullable=False)
notes: Mapped[str | None] = mapped_column(Text)
rest_day: Mapped[bool] = mapped_column(Boolean, default=False)
catalog: Mapped["ProgramCatalog"] = relationship(back_populates="workouts")
__table_args__ = (
UniqueConstraint("catalog_id", "week_number", "day_number", name="uq_catalog_week_day"),
)
class CrawlQueue(Base):
"""Job queue for program research crawls."""
__tablename__ = "crawl_queue"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
)
search_query: Mapped[str] = mapped_column(String(500), nullable=False)
priority: Mapped[str] = mapped_column(String(10), default="low")
status: Mapped[str] = mapped_column(String(20), default="pending")
urls_to_crawl: Mapped[dict] = mapped_column(JSONB, default=list)
crawled_content: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class WorkoutLog(Base):
"""Per-user workout completion tracking against catalog workouts."""
__tablename__ = "workout_logs"
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), ForeignKey("users.id"), nullable=False
)
program_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("programs.id"), nullable=False
)
catalog_workout_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("catalog_workouts.id"), nullable=False
)
week_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
exercises_completed: Mapped[dict | None] = mapped_column(JSONB)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text)

View file

@ -1,37 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from decimal import Decimal
from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class FoodLog(Base):
__tablename__ = "food_log"
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), ForeignKey("users.id"), nullable=False)
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
brand: Mapped[str | None] = mapped_column(String(200))
barcode: Mapped[str | None] = mapped_column(String(50))
serving_size: Mapped[str | None] = mapped_column(String(100))
servings: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=1)
calories: Mapped[Decimal | None] = mapped_column(Numeric(7, 1))
protein_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
carbs_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
fiber_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
sodium_mg: Mapped[Decimal | None] = mapped_column(Numeric(7, 1))
sugar_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1))
food_date: Mapped[date] = mapped_column(Date, nullable=False)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
off_product_id: Mapped[str | None] = mapped_column(String(50))
off_data: Mapped[dict] = mapped_column(JSONB, default=dict)
__table_args__ = (
Index("idx_food_log_user_date", "user_id", "food_date"),
)

View file

@ -1,25 +0,0 @@
"""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

@ -1,65 +0,0 @@
"""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

@ -1,90 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class NutritionGoal(Base):
"""Per-user macro + eating-window targets. One active per user."""
__tablename__ = "nutrition_goals"
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), ForeignKey("users.id"), nullable=False, unique=True)
# Macro targets (grams / kcal)
calorie_target: Mapped[int] = mapped_column(Integer, default=2400)
protein_g_target: Mapped[int] = mapped_column(Integer, default=180)
fat_g_target: Mapped[int] = mapped_column(Integer, default=175)
net_carbs_g_cap: Mapped[int] = mapped_column(Integer, default=20) # strict keto
# Training-day override (optional)
training_calorie_target: Mapped[int | None] = mapped_column(Integer)
training_protein_g_target: Mapped[int | None] = mapped_column(Integer)
training_fat_g_target: Mapped[int | None] = mapped_column(Integer)
# Eating window (24h local time, hours only)
eating_window_start_hour: Mapped[int] = mapped_column(Integer, default=12) # 12:00
eating_window_end_hour: Mapped[int] = mapped_column(Integer, default=20) # 20:00
default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8
diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto")
timezone_str: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
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))
class Fast(Base):
"""A single fasting bout — start/end + type + compliance flag."""
__tablename__ = "fasts"
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), ForeignKey("users.id"), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Target length (hours) — 16, 18, 20, 24, 36, 48, 72
target_hours: Mapped[int] = mapped_column(Integer, nullable=False)
fast_type: Mapped[str] = mapped_column(String(20), default="daily") # daily, weekly_24, long_48, long_72, extended
# Completion state
completed: Mapped[bool] = mapped_column(Boolean, default=False) # hit target
broken_early: Mapped[bool] = mapped_column(Boolean, default=False) # ended before target
# Points granted (for idempotency — don't double-credit)
points_awarded: Mapped[int] = mapped_column(Integer, default=0)
activity_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("activity_log.id"))
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
__table_args__ = (
Index("idx_fasts_user_started", "user_id", "started_at"),
)
class ElectrolyteLog(Base):
"""Daily electrolyte dosing checklist — lightweight tracker."""
__tablename__ = "electrolyte_log"
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), ForeignKey("users.id"), nullable=False)
log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
sodium_mg: Mapped[int] = mapped_column(Integer, default=0)
potassium_mg: Mapped[int] = mapped_column(Integer, default=0)
magnesium_mg: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text)
__table_args__ = (
Index("idx_electrolyte_user_date", "user_id", "log_date"),
)

View file

@ -1,27 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class OAuthToken(Base):
__tablename__ = "oauth_tokens"
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), ForeignKey("users.id"), nullable=False)
provider: Mapped[str] = mapped_column(String(20), nullable=False)
access_token: Mapped[str] = mapped_column(Text, nullable=False)
refresh_token: Mapped[str | None] = mapped_column(Text)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
athlete_id: Mapped[str | None] = mapped_column(String(100))
scope: Mapped[str | None] = mapped_column(String(200))
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), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint("user_id", "provider", name="uq_user_provider"),
)

View file

@ -1,54 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class PointRule(Base):
__tablename__ = "point_rules"
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), ForeignKey("users.id"), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str] = mapped_column(String(200), nullable=False)
points: Mapped[int] = mapped_column(Integer, nullable=False)
unit: Mapped[str | None] = mapped_column(String(20))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="point_rules")
class DailyTarget(Base):
__tablename__ = "daily_targets"
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), ForeignKey("users.id"), nullable=False, unique=True)
daily_minimum_pts: Mapped[int] = mapped_column(Integer, default=80)
weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500)
weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Default rules to create per user
DEFAULT_POINT_RULES = [
{"category": "workout", "description": "Complete a workout", "points": 50, "unit": "per_completion"},
{"category": "food_log", "description": "Log all meals for the day", "points": 30, "unit": "per_day"},
{"category": "steps_target", "description": "Hit daily step/activity goal", "points": 20, "unit": "per_day"},
{"category": "screen_free", "description": "Screen-free activity", "points": 20, "unit": "per_hour"},
{"category": "daily_checkin", "description": "Complete daily check-in", "points": 10, "unit": "per_day"},
]
# Suggested targets by TTM stage
TTM_DAILY_TARGETS = {
"precontemplation": {"daily_minimum_pts": 30, "weekly_target_pts": 150},
"contemplation": {"daily_minimum_pts": 50, "weekly_target_pts": 250},
"preparation": {"daily_minimum_pts": 80, "weekly_target_pts": 400},
"action": {"daily_minimum_pts": 100, "weekly_target_pts": 600},
"maintenance": {"daily_minimum_pts": 120, "weekly_target_pts": 700},
}

View file

@ -1,56 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class UserProfile(Base):
__tablename__ = "user_profiles"
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), ForeignKey("users.id"), nullable=False, unique=True)
# Phase 1
primary_goal: Mapped[str | None] = mapped_column(String(50))
ttm_stage: Mapped[str | None] = mapped_column(String(20))
# Phase 2 - Body
age: Mapped[int | None] = mapped_column(Integer)
height_cm: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
weight_kg: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
gender: Mapped[str | None] = mapped_column(String(20))
# PAR-Q+
parq_heart_condition: Mapped[bool] = mapped_column(Boolean, default=False)
parq_joint_issues: Mapped[bool] = mapped_column(Boolean, default=False)
parq_medications: Mapped[bool] = mapped_column(Boolean, default=False)
parq_other_conditions: Mapped[str | None] = mapped_column(Text)
parq_cleared: Mapped[bool] = mapped_column(Boolean, default=True)
# BREQ-2 motivation
motivation_external: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_introjected: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_identified: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_intrinsic: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_amotivation: Mapped[Decimal | None] = mapped_column(Numeric(3, 1))
motivation_rai: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
# Preferences
activity_preferences: Mapped[dict] = mapped_column(JSONB, default=list)
equipment_access: Mapped[str | None] = mapped_column(String(50)) # legacy — kept for compat
equipment_list: Mapped[dict] = mapped_column(JSONB, default=list) # new: list of equipment strings
days_per_week: Mapped[int] = mapped_column(Integer, default=3)
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)
# Completion
phase1_completed: Mapped[bool] = mapped_column(Boolean, default=False)
phase2_completed: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="profile")

View file

@ -1,32 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Program(Base):
__tablename__ = "programs"
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), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
source: Mapped[str | None] = mapped_column(String(50))
source_url: Mapped[str | None] = mapped_column(Text)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="active")
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# v2: Link to catalog program
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
)
current_week: Mapped[int] = mapped_column(Integer, default=1)
current_day: Mapped[int] = mapped_column(Integer, default=1)
user: Mapped["User"] = relationship(back_populates="programs")

View file

@ -1,27 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Boolean, Text, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class Resource(Base):
__tablename__ = "resource_library"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False)
source: Mapped[str] = mapped_column(String(50), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
thumbnail_url: Mapped[str | None] = mapped_column(Text)
goal_tags: Mapped[dict] = mapped_column(JSONB, default=list)
activity_tags: Mapped[dict] = mapped_column(JSONB, default=list)
equipment_needed: Mapped[str | None] = mapped_column(String(50))
ttm_stages: Mapped[dict] = mapped_column(JSONB, default=list)
difficulty: Mapped[str | None] = mapped_column(String(20))
duration_days: Mapped[int | None] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -1,38 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Reward(Base):
__tablename__ = "rewards"
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), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
point_cost: Mapped[int] = mapped_column(Integer, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="rewards")
class RewardRedemption(Base):
__tablename__ = "reward_redemptions"
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), ForeignKey("users.id"), nullable=False)
reward_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rewards.id"), nullable=False)
points_spent: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
redemption_date: Mapped[date] = mapped_column(Date, nullable=False)
notes: Mapped[str | None] = mapped_column(Text)
__table_args__ = (
Index("idx_redemptions_user_date", "user_id", "redemption_date"),
)

View file

@ -1,54 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class SupportThread(Base):
"""One thread per user — private conversation with admin."""
__tablename__ = "support_threads"
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), ForeignKey("users.id"), unique=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
last_message_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
unread_user: Mapped[int] = mapped_column(Integer, default=0)
unread_admin: Mapped[int] = mapped_column(Integer, default=0)
messages: Mapped[list["SupportMessage"]] = relationship(
back_populates="thread", cascade="all, delete-orphan",
order_by="SupportMessage.created_at"
)
class SupportMessage(Base):
"""Individual message within a support thread."""
__tablename__ = "support_messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
thread_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False
)
sender: Mapped[str] = mapped_column(String(10), nullable=False) # 'user' or 'admin'
body: Mapped[str] = mapped_column(Text, nullable=False)
context: Mapped[dict | None] = mapped_column(JSONB)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
thread: Mapped["SupportThread"] = relationship(back_populates="messages")
__table_args__ = (
Index("idx_support_messages_thread", "thread_id", "created_at"),
)

View file

@ -1,28 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin")
programs: Mapped[list["Program"]] = relationship(back_populates="user", lazy="selectin")
point_rules: Mapped[list["PointRule"]] = relationship(back_populates="user", lazy="selectin")
rewards: Mapped[list["Reward"]] = relationship(back_populates="user", lazy="selectin")

View file

@ -1,95 +0,0 @@
from __future__ import annotations
from typing import Annotated
from datetime import date
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.activity import ActivityLog
from app.schemas.activity import ActivityCreate, ActivityResponse
from app.utils.auth import get_current_user
from app.services.points_engine import log_activity_with_points
router = APIRouter(prefix="/api/activities", tags=["activities"])
@router.get("")
async def list_activities(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
activity_date: date | None = Query(None, alias="date"),
):
query = select(ActivityLog).where(ActivityLog.user_id == user.id)
if activity_date:
query = query.where(ActivityLog.activity_date == activity_date)
query = query.order_by(ActivityLog.logged_at.desc()).limit(50)
result = await db.execute(query)
activities = result.scalars().all()
return [
{
"id": str(a.id),
"category": a.category,
"title": a.title,
"description": a.description,
"duration_minutes": a.duration_minutes,
"source": a.source,
"points_earned": a.points_earned,
"activity_date": a.activity_date.isoformat(),
"logged_at": a.logged_at.isoformat() if a.logged_at else None,
"program_day": a.program_day,
"metadata": a.metadata_json,
}
for a in activities
]
@router.post("")
async def create_activity(
req: ActivityCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
import uuid as uuid_mod
program_id = uuid_mod.UUID(req.program_id) if req.program_id else None
entry = await log_activity_with_points(
db=db,
user_id=user.id,
category=req.category,
activity_date=req.activity_date,
title=req.title,
description=req.description,
duration_minutes=req.duration_minutes,
program_id=program_id,
program_day=req.program_day,
metadata=req.metadata,
)
return {
"id": str(entry.id),
"category": entry.category,
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": entry.activity_date.isoformat(),
}
@router.delete("/{activity_id}")
async def delete_activity(
activity_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
import uuid as uuid_mod
aid = uuid_mod.UUID(activity_id)
result = await db.execute(
select(ActivityLog).where(ActivityLog.id == aid, ActivityLog.user_id == user.id)
)
entry = result.scalar_one_or_none()
if not entry:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(entry)
return {"status": "deleted"}

View file

@ -1,64 +0,0 @@
"""AI coaching chat endpoint — SSE streaming responses."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.utils.auth import get_current_user
from app.models.user import User
from app.services import ai_provider
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai", tags=["AI Coaching"])
@router.post("/chat")
async def chat_with_ai(
request: Request,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Stream an AI coaching response via SSE.
Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]}
Response: text/event-stream with data chunks.
"""
body = await request.json()
message = body.get("message", "").strip()
history = body.get("history", [])
if not message:
return {"error": "Message cannot be empty"}
# Validate history format
clean_history = []
for msg in history[-20:]: # Cap at last 20 messages
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"):
clean_history.append({"role": msg["role"], "content": msg["content"][:4000]})
async def event_stream():
async for chunk in ai_provider.chat(user.id, message, clean_history, db):
yield f"data: {json.dumps({'text': chunk})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.get("/status")
async def ai_status(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Check which AI provider is configured."""
provider = await ai_provider.get_active_ai_provider(db, user_id=user.id)
if provider:
return {
"configured": True,
"provider": provider["name"],
"model": provider["model"],
}
return {"configured": False, "provider": None, "model": None}

View file

@ -1,88 +0,0 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
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.profile import UserProfile
from app.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user
logger = logging.getLogger("fitness-rewards")
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register")
async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)]):
try:
existing = await db.execute(select(User).where(User.username == req.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
# Grant admin to first user
admin_count = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
is_first_user = (admin_count.scalar() or 0) == 0
user = User(
username=req.username,
display_name=req.display_name,
password_hash=hash_password(req.password),
email=req.email,
is_admin=is_first_user,
)
db.add(user)
await db.flush()
# Create empty profile
profile = UserProfile(user_id=user.id)
db.add(profile)
# Create default point rules
for rule_data in DEFAULT_POINT_RULES:
db.add(PointRule(user_id=user.id, **rule_data))
# Create default daily targets
db.add(DailyTarget(user_id=user.id))
await db.flush()
token = create_access_token(str(user.id))
return {"access_token": token, "token_type": "bearer"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration failed: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.post("/login")
async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)]):
try:
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(str(user.id))
return {"access_token": token, "token_type": "bearer"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Login failed: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.get("/me")
async def get_me(user: Annotated[User, Depends(get_current_user)]):
return {
"id": str(user.id),
"username": user.username,
"display_name": user.display_name,
"email": user.email,
"timezone": user.timezone,
"is_admin": getattr(user, "is_admin", False),
}

View file

@ -1,673 +0,0 @@
"""Catalog router — program research, browsing, adoption, and workout tracking."""
from __future__ import annotations
import uuid as uuid_mod
from datetime import date, timedelta, 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 sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.program import Program
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
from app.services.program_research import slugify, find_urls_for_program
from app.services.points_engine import log_activity_with_points
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/programs", tags=["programs-v2"])
# ── Schemas ─────────────────────────────────────────────────────────────────
class ResearchRequest(BaseModel):
name: str
class AdoptRequest(BaseModel):
start_date: date
class CompleteWorkoutRequest(BaseModel):
exercises_completed: list[dict] | None = None
notes: str | None = None
# ── Catalog Endpoints ───────────────────────────────────────────────────────
@router.get("/catalog")
async def list_catalog(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
q: str | None = None,
):
"""List all programs in catalog, optionally filtered by search query."""
query = select(ProgramCatalog).order_by(ProgramCatalog.created_at.desc())
if q:
query = query.where(ProgramCatalog.name.ilike(f"%{q}%"))
result = await db.execute(query)
programs = result.scalars().all()
return [
{
"id": str(p.id),
"name": p.name,
"slug": p.slug,
"description": p.description,
"duration_weeks": p.duration_weeks,
"frequency_per_week": p.frequency_per_week,
"equipment": p.equipment or [],
"difficulty": p.difficulty,
"category": p.category,
"crawl_status": p.crawl_status,
"source_url": p.source_url,
}
for p in programs
]
@router.get("/catalog/{catalog_id}")
async def get_catalog_program(
catalog_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get full catalog program detail including workouts."""
result = await db.execute(
select(ProgramCatalog)
.options(selectinload(ProgramCatalog.workouts))
.where(ProgramCatalog.id == uuid_mod.UUID(catalog_id))
)
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404, detail="Program not found in catalog")
workouts = []
for w in p.workouts:
workouts.append({
"id": str(w.id),
"week_number": w.week_number,
"day_number": w.day_number,
"workout_name": w.workout_name,
"exercises": w.exercises,
"notes": w.notes,
"rest_day": w.rest_day,
})
return {
"id": str(p.id),
"name": p.name,
"description": p.description,
"duration_weeks": p.duration_weeks,
"frequency_per_week": p.frequency_per_week,
"equipment": p.equipment or [],
"difficulty": p.difficulty,
"category": p.category,
"progression_rules": p.progression_rules,
"source_url": p.source_url,
"crawl_status": p.crawl_status,
"crawl_error": p.crawl_error,
"workouts": workouts,
}
@router.post("/research")
async def research_program(
req: ResearchRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Submit a program name for research. Creates catalog entry + crawl job."""
slug = slugify(req.name)
# Check if already in catalog
result = await db.execute(
select(ProgramCatalog).where(ProgramCatalog.slug == slug)
)
existing = result.scalar_one_or_none()
if existing:
return {
"id": str(existing.id),
"name": existing.name,
"crawl_status": existing.crawl_status,
"already_exists": True,
}
# Find URLs
urls = find_urls_for_program(req.name)
# Create catalog entry
catalog = ProgramCatalog(
name=req.name.strip(),
slug=slug,
crawl_status="pending",
source_url=urls[0] if urls else None,
)
db.add(catalog)
await db.flush()
# Create crawl job
job = CrawlQueue(
catalog_id=catalog.id,
search_query=req.name.strip(),
priority="low",
urls_to_crawl=urls,
)
db.add(job)
await db.flush()
return {
"id": str(catalog.id),
"name": catalog.name,
"crawl_status": catalog.crawl_status,
"already_exists": False,
"urls_found": len(urls),
"message": "Program queued for research. It will be processed during off-peak hours."
if not urls
else f"Program queued for research with {len(urls)} known source(s).",
}
@router.post("/catalog/{catalog_id}/adopt")
async def adopt_program(
catalog_id: str,
req: AdoptRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""User adopts a catalog program — creates their personal program enrollment."""
result = await db.execute(
select(ProgramCatalog).where(
ProgramCatalog.id == uuid_mod.UUID(catalog_id),
ProgramCatalog.crawl_status == "ready",
)
)
catalog = result.scalar_one_or_none()
if not catalog:
raise HTTPException(
status_code=404,
detail="Program not found or not ready yet"
)
# Check if user already has this program active
result = await db.execute(
select(Program).where(
Program.user_id == user.id,
Program.catalog_id == uuid_mod.UUID(catalog_id),
Program.status == "active",
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="You already have this program active")
# Calculate end date
duration_weeks = catalog.duration_weeks or 12
end_date = req.start_date + timedelta(weeks=duration_weeks)
program = Program(
user_id=user.id,
name=catalog.name,
source="catalog",
source_url=catalog.source_url,
start_date=req.start_date,
end_date=end_date,
status="active",
catalog_id=catalog.id,
current_week=1,
current_day=1,
)
db.add(program)
await db.flush()
return {
"id": str(program.id),
"name": program.name,
"start_date": program.start_date.isoformat(),
"end_date": program.end_date.isoformat(),
"catalog_id": str(catalog.id),
}
# ── Program Tracking Endpoints ────────────────────────────────────────────────
#
# Workout ID encoding:
# The catalog stores a TEMPLATE (typically week 1 only), and the program
# rotates that template across `duration_weeks` real weeks. To allow the same
# template workout to be completed once per real week, schedule entries use
# a synthetic ID format: "{template_uuid}::{real_week}".
#
# Frontend treats the ID as opaque. Backend parses it on every workout
# endpoint to recover (template_workout_id, real_week_number).
def _make_workout_id(template_id: uuid_mod.UUID, real_week: int) -> str:
return f"{template_id}::{real_week}"
def _parse_workout_id(workout_id: str) -> tuple[uuid_mod.UUID, int]:
"""Parse a synthetic workout ID. Falls back to (uuid, 1) for legacy IDs."""
if "::" in workout_id:
uuid_part, week_part = workout_id.split("::", 1)
return uuid_mod.UUID(uuid_part), int(week_part)
return uuid_mod.UUID(workout_id), 1
async def _load_template_workouts(db: AsyncSession, catalog_id: uuid_mod.UUID):
"""Returns (template_workouts_list, template_weeks_count)."""
result = await db.execute(
select(CatalogWorkout)
.where(CatalogWorkout.catalog_id == catalog_id)
.order_by(CatalogWorkout.week_number, CatalogWorkout.day_number)
)
workouts = result.scalars().all()
template_weeks = max((w.week_number for w in workouts), default=1)
return workouts, template_weeks
def _build_rotated_schedule(template_workouts, template_weeks, total_weeks, completed_keys):
"""
Generate the full rotated schedule across `total_weeks` real program weeks.
`completed_keys` is a set of (catalog_workout_id, week_number) tuples.
Returns list of schedule entry dicts.
"""
schedule = []
for real_week in range(1, total_weeks + 1):
template_week = ((real_week - 1) % template_weeks) + 1
for w in template_workouts:
if w.week_number != template_week:
continue
schedule.append({
"id": _make_workout_id(w.id, real_week),
"template_id": str(w.id),
"week_number": real_week,
"day_number": w.day_number,
"workout_name": w.workout_name,
"exercises": w.exercises,
"rest_day": w.rest_day,
"notes": w.notes,
"completed": (w.id, real_week) in completed_keys,
})
return schedule
@router.get("/{program_id}/schedule")
async def get_program_schedule(
program_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get the user's program schedule — today's workout, upcoming, and completed."""
result = await db.execute(
select(Program).where(
Program.id == uuid_mod.UUID(program_id),
Program.user_id == user.id,
)
)
program = result.scalar_one_or_none()
if not program or not program.catalog_id:
raise HTTPException(status_code=404)
# Load catalog + template workouts
result = await db.execute(
select(ProgramCatalog).where(ProgramCatalog.id == program.catalog_id)
)
catalog = result.scalar_one_or_none()
if not catalog:
raise HTTPException(status_code=404, detail="Catalog program missing")
template_workouts, template_weeks = await _load_template_workouts(db, program.catalog_id)
if not template_workouts:
raise HTTPException(status_code=404, detail="No workouts in catalog yet")
total_weeks = catalog.duration_weeks or template_weeks
# Completed (catalog_workout_id, week_number) pairs
result = await db.execute(
select(WorkoutLog.catalog_workout_id, WorkoutLog.week_number).where(
WorkoutLog.program_id == program.id,
WorkoutLog.user_id == user.id,
)
)
completed_keys = {(row[0], row[1]) for row in result.all()}
# Build full rotated schedule
schedule = _build_rotated_schedule(
template_workouts, template_weeks, total_weeks, completed_keys
)
# Calculate current real week from start date
today = date.today()
days_elapsed = max(0, (today - program.start_date).days)
current_week = min(total_weeks, (days_elapsed // 7) + 1)
# Today's workout: the first uncompleted, non-rest entry in the current real week.
# This is more flexible than literal day-of-week matching — the user can do
# any of the week's workouts on any calendar day.
today_workout = None
for entry in schedule:
if entry["week_number"] == current_week and not entry["rest_day"] and not entry["completed"]:
today_workout = entry
break
total_workouts = sum(1 for e in schedule if not e["rest_day"])
completed_workouts = sum(1 for e in schedule if e["completed"] and not e["rest_day"])
return {
"program_id": str(program.id),
"program_name": program.name,
"start_date": program.start_date.isoformat(),
"end_date": program.end_date.isoformat(),
"current_week": current_week,
"total_weeks": total_weeks,
"today_workout": today_workout,
"schedule": schedule,
"total_workouts": total_workouts,
"completed_workouts": completed_workouts,
"progression_rules": catalog.progression_rules,
}
@router.get("/{program_id}/workout/{workout_id}")
async def get_workout_detail(
program_id: str,
workout_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a specific workout's full exercise details (parses synthetic ID)."""
template_id, real_week = _parse_workout_id(workout_id)
result = await db.execute(
select(Program).where(
Program.id == uuid_mod.UUID(program_id),
Program.user_id == user.id,
)
)
program = result.scalar_one_or_none()
if not program:
raise HTTPException(status_code=404)
result = await db.execute(
select(CatalogWorkout).where(
CatalogWorkout.id == template_id,
CatalogWorkout.catalog_id == program.catalog_id,
)
)
workout = result.scalar_one_or_none()
if not workout:
raise HTTPException(status_code=404)
# Check completed for this specific real week
result = await db.execute(
select(WorkoutLog).where(
WorkoutLog.program_id == program.id,
WorkoutLog.catalog_workout_id == workout.id,
WorkoutLog.week_number == real_week,
WorkoutLog.user_id == user.id,
)
)
log = result.scalar_one_or_none()
return {
"id": _make_workout_id(workout.id, real_week),
"template_id": str(workout.id),
"week_number": real_week,
"day_number": workout.day_number,
"workout_name": workout.workout_name,
"exercises": workout.exercises,
"rest_day": workout.rest_day,
"notes": workout.notes,
"completed": log is not None,
"completed_at": log.completed_at.isoformat() if log else None,
"exercises_completed": log.exercises_completed if log else None,
}
@router.post("/{program_id}/workout/{workout_id}/complete")
async def complete_workout(
program_id: str,
workout_id: str,
req: CompleteWorkoutRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Mark a program workout as complete (parses synthetic ID). Awards bonus points."""
template_id, real_week = _parse_workout_id(workout_id)
# Verify program
result = await db.execute(
select(Program).where(
Program.id == uuid_mod.UUID(program_id),
Program.user_id == user.id,
Program.status == "active",
)
)
program = result.scalar_one_or_none()
if not program:
raise HTTPException(status_code=404, detail="Active program not found")
# Verify workout belongs to program's catalog
result = await db.execute(
select(CatalogWorkout).where(
CatalogWorkout.id == template_id,
CatalogWorkout.catalog_id == program.catalog_id,
)
)
workout = result.scalar_one_or_none()
if not workout:
raise HTTPException(status_code=404, detail="Workout not found")
if workout.rest_day:
raise HTTPException(status_code=400, detail="Cannot complete a rest day")
# Check not already completed for THIS real week
result = await db.execute(
select(WorkoutLog).where(
WorkoutLog.program_id == program.id,
WorkoutLog.catalog_workout_id == workout.id,
WorkoutLog.week_number == real_week,
WorkoutLog.user_id == user.id,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workout already completed for this week")
PROGRAM_WORKOUT_POINTS = 75
log = WorkoutLog(
user_id=user.id,
program_id=program.id,
catalog_workout_id=workout.id,
week_number=real_week,
exercises_completed=req.exercises_completed,
points_earned=PROGRAM_WORKOUT_POINTS,
notes=req.notes,
)
db.add(log)
# Also log as activity for the points engine
activity = await log_activity_with_points(
db=db,
user_id=user.id,
category="workout",
activity_date=date.today(),
title=f"{program.name}: Wk{real_week} {workout.workout_name or f'Day {workout.day_number}'}",
description=f"Completed program workout ({len(workout.exercises)} exercises)",
source="program",
program_id=program.id,
program_day=workout.day_number,
metadata={
"catalog_workout_id": str(workout.id),
"real_week": real_week,
"program_points": True,
},
)
if activity.points_earned < PROGRAM_WORKOUT_POINTS:
activity.points_earned = PROGRAM_WORKOUT_POINTS
await db.flush()
# Bonuses
weekly_bonus = await check_weekly_bonus(db, user, program, real_week)
completion_bonus = await check_completion_bonus(db, user, program)
total_points = PROGRAM_WORKOUT_POINTS + weekly_bonus + completion_bonus
return {
"workout_log_id": str(log.id),
"points_earned": PROGRAM_WORKOUT_POINTS,
"weekly_bonus": weekly_bonus,
"completion_bonus": completion_bonus,
"total_points": total_points,
"workout_name": workout.workout_name,
}
async def check_weekly_bonus(
db: AsyncSession, user: User, program: Program, real_week: int
) -> int:
"""Award bonus when all template workouts for the current real week are done."""
WEEKLY_BONUS = 50
# Get template metadata
template_workouts, template_weeks = await _load_template_workouts(db, program.catalog_id)
template_week = ((real_week - 1) % template_weeks) + 1
week_template_workouts = [w for w in template_workouts if w.week_number == template_week and not w.rest_day]
total_in_week = len(week_template_workouts)
if total_in_week == 0:
return 0
# Count completed workout_logs for this real week
template_ids = [w.id for w in week_template_workouts]
result = await db.execute(
select(func.count(WorkoutLog.id)).where(
WorkoutLog.program_id == program.id,
WorkoutLog.user_id == user.id,
WorkoutLog.week_number == real_week,
WorkoutLog.catalog_workout_id.in_(template_ids),
)
)
completed_in_week = result.scalar() or 0
if completed_in_week >= total_in_week:
await log_activity_with_points(
db=db,
user_id=user.id,
category="bonus",
activity_date=date.today(),
title=f"{program.name}: Week {real_week} Complete!",
source="program",
program_id=program.id,
metadata={"weekly_bonus": True, "week": real_week},
)
return WEEKLY_BONUS
return 0
async def check_completion_bonus(
db: AsyncSession, user: User, program: Program
) -> int:
"""Award completion bonus when all (template × weeks) workouts done."""
COMPLETION_BONUS = 200
result = await db.execute(
select(ProgramCatalog).where(ProgramCatalog.id == program.catalog_id)
)
catalog = result.scalar_one_or_none()
if not catalog:
return 0
template_workouts, template_weeks = await _load_template_workouts(db, program.catalog_id)
total_weeks = catalog.duration_weeks or template_weeks
template_per_week = sum(1 for w in template_workouts if not w.rest_day) // max(1, template_weeks)
total = template_per_week * total_weeks
result = await db.execute(
select(func.count(WorkoutLog.id)).where(
WorkoutLog.program_id == program.id,
WorkoutLog.user_id == user.id,
)
)
completed = result.scalar() or 0
if completed >= total and total > 0:
program.status = "completed"
await log_activity_with_points(
db=db,
user_id=user.id,
category="bonus",
activity_date=date.today(),
title=f"{program.name}: Program Complete!",
source="program",
program_id=program.id,
metadata={"completion_bonus": True},
)
return COMPLETION_BONUS
return 0
@router.get("/{program_id}/progress")
async def get_program_progress(
program_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Program progress overview — accounts for full template×weeks total."""
result = await db.execute(
select(Program).where(
Program.id == uuid_mod.UUID(program_id),
Program.user_id == user.id,
)
)
program = result.scalar_one_or_none()
if not program or not program.catalog_id:
raise HTTPException(status_code=404)
result = await db.execute(
select(ProgramCatalog).where(ProgramCatalog.id == program.catalog_id)
)
catalog = result.scalar_one_or_none()
template_workouts, template_weeks = await _load_template_workouts(db, program.catalog_id)
total_weeks = (catalog.duration_weeks if catalog else None) or template_weeks
template_non_rest = sum(1 for w in template_workouts if not w.rest_day)
template_per_week = template_non_rest // max(1, template_weeks)
total = template_per_week * total_weeks
result = await db.execute(
select(func.count(WorkoutLog.id)).where(
WorkoutLog.program_id == program.id,
WorkoutLog.user_id == user.id,
)
)
completed = result.scalar() or 0
result = await db.execute(
select(func.coalesce(func.sum(WorkoutLog.points_earned), 0)).where(
WorkoutLog.program_id == program.id,
WorkoutLog.user_id == user.id,
)
)
total_points = result.scalar() or 0
today = date.today()
days_elapsed = max(0, (today - program.start_date).days)
current_week = min(total_weeks, (days_elapsed // 7) + 1)
return {
"program_id": str(program.id),
"name": program.name,
"status": program.status,
"start_date": program.start_date.isoformat(),
"end_date": program.end_date.isoformat(),
"current_week": current_week,
"total_weeks": total_weeks,
"total_workouts": total,
"completed_workouts": completed,
"completion_pct": round((completed / total * 100) if total > 0 else 0, 1),
"total_points_earned": total_points,
}

View file

@ -1,113 +0,0 @@
from __future__ import annotations
from typing import Annotated
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.food import FoodLog
from app.schemas.food import FoodCreate, FoodSearchResult
from app.utils.auth import get_current_user
from app.services.food_lookup import lookup_barcode, search_food
router = APIRouter(prefix="/api/food", tags=["food"])
@router.get("")
async def list_food(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
food_date: date | None = Query(None, alias="date"),
):
query = select(FoodLog).where(FoodLog.user_id == user.id)
if food_date:
query = query.where(FoodLog.food_date == food_date)
query = query.order_by(FoodLog.logged_at.desc()).limit(100)
result = await db.execute(query)
items = result.scalars().all()
# Group by meal type
meals = {"breakfast": [], "lunch": [], "dinner": [], "snack": []}
total_cals = 0
for item in items:
entry = {
"id": str(item.id),
"food_name": item.food_name,
"brand": item.brand,
"serving_size": item.serving_size,
"servings": float(item.servings) if item.servings else 1,
"calories": float(item.calories) if item.calories else None,
"protein_g": float(item.protein_g) if item.protein_g else None,
"carbs_g": float(item.carbs_g) if item.carbs_g else None,
"fat_g": float(item.fat_g) if item.fat_g else None,
}
meals.get(item.meal_type, meals["snack"]).append(entry)
if item.calories:
total_cals += float(item.calories) * float(item.servings or 1)
return {"date": (food_date or date.today()).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
@router.post("")
async def log_food(
req: FoodCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
entry = FoodLog(
user_id=user.id,
meal_type=req.meal_type,
food_name=req.food_name,
brand=req.brand,
barcode=req.barcode,
serving_size=req.serving_size,
servings=Decimal(str(req.servings)),
calories=Decimal(str(req.calories)) if req.calories else None,
protein_g=Decimal(str(req.protein_g)) if req.protein_g else None,
carbs_g=Decimal(str(req.carbs_g)) if req.carbs_g else None,
fat_g=Decimal(str(req.fat_g)) if req.fat_g else None,
fiber_g=Decimal(str(req.fiber_g)) if req.fiber_g else None,
sodium_mg=Decimal(str(req.sodium_mg)) if req.sodium_mg else None,
sugar_g=Decimal(str(req.sugar_g)) if req.sugar_g else None,
food_date=req.food_date,
off_product_id=req.off_product_id,
off_data=req.off_data,
)
db.add(entry)
await db.flush()
return {"id": str(entry.id), "food_name": entry.food_name, "calories": float(entry.calories) if entry.calories else None}
@router.delete("/{food_id}")
async def delete_food(
food_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
import uuid
result = await db.execute(
select(FoodLog).where(FoodLog.id == uuid.UUID(food_id), FoodLog.user_id == user.id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=404, detail="Food entry not found")
await db.delete(entry)
return {"status": "deleted"}
@router.get("/scan/{barcode}")
async def scan_barcode(barcode: str):
result = await lookup_barcode(barcode)
if not result:
raise HTTPException(status_code=404, detail="Product not found in Open Food Facts")
return result
@router.get("/search")
async def food_search(q: str = Query(..., min_length=2)):
results = await search_food(q)
return {"results": results}

View file

@ -1,289 +0,0 @@
from __future__ import annotations
from typing import Annotated
from datetime import datetime, timezone
import uuid as uuid_mod
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.oauth import OAuthToken
from app.utils.auth import get_current_user
from app.services.strava_sync import (
get_strava_auth_url, exchange_strava_code, sync_strava_activities,
)
from app.services.polar_sync import (
get_polar_auth_url, exchange_polar_code, register_polar_user, sync_polar_activities,
)
router = APIRouter(prefix="/api/integrations", tags=["integrations"])
@router.get("")
async def integration_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(OAuthToken).where(OAuthToken.user_id == user.id))
tokens = result.scalars().all()
providers = {t.provider: {"connected": True, "athlete_id": t.athlete_id} for t in tokens}
return {
"strava": providers.get("strava", {"connected": False}),
"polar": providers.get("polar", {"connected": False}),
}
# --- Strava ---
@router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
from app.utils.auth import create_access_token
from datetime import timedelta
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
return {"auth_url": get_strava_auth_url(state_token)}
@router.get("/strava/callback")
async def strava_callback(
code: str = Query(...),
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
from jose import JWTError, jwt as jose_jwt
from app.config import get_settings
_settings = get_settings()
try:
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
user_id = uuid_mod.UUID(payload["sub"])
except (JWTError, KeyError, ValueError):
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
data = await exchange_strava_code(code)
# Upsert token
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "strava")
)
token = result.scalar_one_or_none()
if token:
token.access_token = data["access_token"]
token.refresh_token = data["refresh_token"]
token.expires_at = datetime.fromtimestamp(data["expires_at"], tz=timezone.utc)
token.athlete_id = str(data.get("athlete", {}).get("id", ""))
token.updated_at = datetime.now(timezone.utc)
else:
token = OAuthToken(
user_id=user_id,
provider="strava",
access_token=data["access_token"],
refresh_token=data["refresh_token"],
expires_at=datetime.fromtimestamp(data["expires_at"], tz=timezone.utc),
athlete_id=str(data.get("athlete", {}).get("id", "")),
scope=data.get("scope", ""),
)
db.add(token)
await db.flush()
from app.config import get_settings
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?strava=connected")
@router.post("/strava/sync")
async def strava_sync(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
imported = await sync_strava_activities(db, user.id)
return {"imported": len(imported), "activities": imported}
# --- Polar ---
@router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
from app.utils.auth import create_access_token
from datetime import timedelta
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
return {"auth_url": get_polar_auth_url(state_token)}
@router.get("/polar/callback")
async def polar_callback(
code: str = Query(...),
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
from jose import JWTError, jwt as jose_jwt
from app.config import get_settings
_settings = get_settings()
try:
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
user_id = uuid_mod.UUID(payload["sub"])
except (JWTError, KeyError, ValueError):
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
data = await exchange_polar_code(code)
# Register user with Polar
await register_polar_user(data["access_token"])
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "polar")
)
token = result.scalar_one_or_none()
if token:
token.access_token = data["access_token"]
token.athlete_id = str(data.get("x_user_id", ""))
token.updated_at = datetime.now(timezone.utc)
else:
token = OAuthToken(
user_id=user_id,
provider="polar",
access_token=data["access_token"],
athlete_id=str(data.get("x_user_id", "")),
)
db.add(token)
await db.flush()
from app.config import get_settings
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?polar=connected")
@router.post("/polar/sync")
async def polar_sync(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
imported = await sync_polar_activities(db, user.id)
return {"imported": len(imported), "activities": imported}
# --- Disconnect ---
@router.delete("/{provider}")
async def disconnect(
provider: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if provider not in ("strava", "polar"):
raise HTTPException(status_code=400, detail="Invalid provider")
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user.id, OAuthToken.provider == provider)
)
token = result.scalar_one_or_none()
if token:
await db.delete(token)
return {"status": "disconnected", "provider": provider}
# --- Dynamic Integration Config (v3) ---
from pydantic import BaseModel
from app.models.integration_config import IntegrationConfig
from app.services.provider_registry import PROVIDER_REGISTRY
from app.services.crypto import encrypt_value, decrypt_value
from app.config import settings
class ConfigureRequest(BaseModel):
provider: str
credentials: dict[str, str]
@router.get("/status")
async def full_integration_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Return connection status for all providers. Never returns actual credentials."""
# OAuth tokens (Strava, Polar)
oauth_result = await db.execute(select(OAuthToken).where(OAuthToken.user_id == user.id))
oauth_tokens = {t.provider: True for t in oauth_result.scalars().all()}
# Dynamic config entries
config_result = await db.execute(
select(IntegrationConfig.provider)
.where(IntegrationConfig.user_id == user.id)
.distinct()
)
configured_providers = {row[0] for row in config_result.all()}
# Also check env vars for backward compatibility
env_providers = set()
if settings.strava_client_id:
env_providers.add("strava")
if settings.polar_client_id:
env_providers.add("polar")
if settings.telegram_bot_token:
env_providers.add("telegram")
if settings.groq_api_key:
env_providers.add("groq")
status = {}
for key, info in PROVIDER_REGISTRY.items():
if key in oauth_tokens:
status[key] = "connected"
elif key in configured_providers or key in env_providers:
status[key] = "configured"
else:
status[key] = "not_configured"
return status
@router.get("/providers")
async def list_providers():
"""Return the provider registry with setup instructions."""
result = {}
for key, info in PROVIDER_REGISTRY.items():
result[key] = {
"name": info["name"],
"type": info["type"],
"fields": info["fields"],
"help_url": info.get("help_url", ""),
"help_text": info.get("help_text", "").replace("{BASE_URL}", settings.base_url),
}
return result
@router.post("/configure")
async def configure_integration(
body: ConfigureRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Store encrypted integration credentials. Write-only — values can never be read back."""
if body.provider not in PROVIDER_REGISTRY:
raise HTTPException(400, f"Unknown provider: {body.provider}")
info = PROVIDER_REGISTRY[body.provider]
expected_fields = set(info["fields"])
provided_fields = set(body.credentials.keys())
missing = expected_fields - provided_fields
if missing:
raise HTTPException(400, f"Missing required fields: {', '.join(missing)}")
for key, value in body.credentials.items():
encrypted = encrypt_value(settings.secret_key, value)
# Upsert
existing = await db.execute(
select(IntegrationConfig).where(
IntegrationConfig.user_id == user.id,
IntegrationConfig.provider == body.provider,
IntegrationConfig.config_key == key,
)
)
row = existing.scalar_one_or_none()
if row:
row.config_value = encrypted
row.updated_at = datetime.now(timezone.utc)
else:
db.add(IntegrationConfig(
user_id=user.id,
provider=body.provider,
config_key=key,
config_value=encrypted,
))
return {"status": "configured", "provider": body.provider}

View file

@ -1,295 +0,0 @@
"""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

@ -1,372 +0,0 @@
from __future__ import annotations
"""Nutrition router — goals, fasts, electrolytes, daily compliance."""
import uuid
from typing import Annotated
from datetime import datetime, date, timezone, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
from app.models.food import FoodLog
from app.schemas.nutrition import (
NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn,
)
from app.utils.auth import get_current_user
from app.services.points_engine import log_activity_with_points
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
# ---------- Goals ----------
async def _get_or_create_goal(db: AsyncSession, user_id: uuid.UUID) -> NutritionGoal:
result = await db.execute(select(NutritionGoal).where(NutritionGoal.user_id == user_id))
goal = result.scalar_one_or_none()
if goal:
return goal
goal = NutritionGoal(user_id=user_id)
db.add(goal)
await db.flush()
return goal
def _goal_to_out(g: NutritionGoal) -> dict:
return {
"id": str(g.id),
"calorie_target": g.calorie_target,
"protein_g_target": g.protein_g_target,
"fat_g_target": g.fat_g_target,
"net_carbs_g_cap": g.net_carbs_g_cap,
"training_calorie_target": g.training_calorie_target,
"training_protein_g_target": g.training_protein_g_target,
"training_fat_g_target": g.training_fat_g_target,
"eating_window_start_hour": g.eating_window_start_hour,
"eating_window_end_hour": g.eating_window_end_hour,
"default_fast_hours": g.default_fast_hours,
"diet_style": g.diet_style,
"timezone_str": g.timezone_str,
}
@router.get("/goals")
async def get_goals(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
goal = await _get_or_create_goal(db, user.id)
return _goal_to_out(goal)
@router.patch("/goals")
async def update_goals(
req: NutritionGoalIn,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
goal = await _get_or_create_goal(db, user.id)
for k, v in req.model_dump(exclude_unset=True).items():
if v is not None:
setattr(goal, k, v)
goal.updated_at = datetime.now(timezone.utc)
await db.flush()
return _goal_to_out(goal)
# ---------- Today's macro status ----------
@router.get("/today")
async def get_today(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Today's macros vs target, eating-window state, active fast, compliance."""
goal = await _get_or_create_goal(db, user.id)
today = date.today()
# Sum today's food
result = await db.execute(
select(
func.coalesce(func.sum(FoodLog.calories * FoodLog.servings), 0),
func.coalesce(func.sum(FoodLog.protein_g * FoodLog.servings), 0),
func.coalesce(func.sum(FoodLog.carbs_g * FoodLog.servings), 0),
func.coalesce(func.sum(FoodLog.fat_g * FoodLog.servings), 0),
func.coalesce(func.sum(FoodLog.fiber_g * FoodLog.servings), 0),
).where(FoodLog.user_id == user.id, FoodLog.food_date == today)
)
cals, prot, carbs, fat, fiber = result.one()
cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber)
net_carbs = max(0.0, carbs - fiber)
# Eating window (local time, using goal timezone_str — simplified: assume server in UTC, user sends local)
now_local = datetime.now(timezone.utc) + timedelta(hours=7) # Asia/Bangkok offset hack
hour = now_local.hour
in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour
window_str = f"{goal.eating_window_start_hour:02d}:00{goal.eating_window_end_hour:02d}:00"
# Active fast
result = await db.execute(
select(Fast).where(Fast.user_id == user.id, Fast.ended_at.is_(None))
.order_by(Fast.started_at.desc()).limit(1)
)
active_fast = result.scalar_one_or_none()
active_fast_data = None
if active_fast:
elapsed = (datetime.now(timezone.utc) - active_fast.started_at).total_seconds() / 3600
active_fast_data = {
"id": str(active_fast.id),
"started_at": active_fast.started_at.isoformat(),
"target_hours": active_fast.target_hours,
"fast_type": active_fast.fast_type,
"elapsed_hours": round(elapsed, 2),
"target_reached": elapsed >= active_fast.target_hours,
}
# Compliance for today
carb_ok = net_carbs <= goal.net_carbs_g_cap
protein_ok = prot >= goal.protein_g_target * 0.85 # 85% threshold gives some slack
compliant_day = carb_ok and protein_ok
return {
"date": today.isoformat(),
"macros": {
"calories": round(cals, 0),
"protein_g": round(prot, 1),
"carbs_g": round(carbs, 1),
"net_carbs_g": round(net_carbs, 1),
"fat_g": round(fat, 1),
"fiber_g": round(fiber, 1),
},
"targets": {
"calories": goal.calorie_target,
"protein_g": goal.protein_g_target,
"fat_g": goal.fat_g_target,
"net_carbs_cap": goal.net_carbs_g_cap,
},
"compliance": {
"carb_ok": carb_ok,
"protein_ok": protein_ok,
"compliant_day": compliant_day,
},
"eating_window": {
"start": goal.eating_window_start_hour,
"end": goal.eating_window_end_hour,
"display": window_str,
"in_window_now": in_window,
},
"active_fast": active_fast_data,
}
# ---------- Fasts ----------
def _fast_to_out(f: Fast) -> dict:
if f.ended_at:
elapsed = (f.ended_at - f.started_at).total_seconds() / 3600
else:
elapsed = (datetime.now(timezone.utc) - f.started_at).total_seconds() / 3600
return {
"id": str(f.id),
"started_at": f.started_at.isoformat(),
"ended_at": f.ended_at.isoformat() if f.ended_at else None,
"target_hours": f.target_hours,
"fast_type": f.fast_type,
"completed": f.completed,
"broken_early": f.broken_early,
"points_awarded": f.points_awarded,
"elapsed_hours": round(elapsed, 2),
"notes": f.notes,
}
# Points schedule per design doc
FAST_POINTS = {
16: 25, # 16:8 daily
18: 40, # 18:6
20: 60, # 20:4
24: 200, # 24h weekly
36: 350,
48: 500, # 48h monthly
72: 1000, # 72h kickoff
}
def _points_for_fast(target_hours: int, elapsed_hours: float) -> int:
"""Award the highest tier the user actually reached, capped at target."""
# Find the highest tier <= min(target, elapsed)
achieved = min(target_hours, int(elapsed_hours))
best = 0
for tier, pts in FAST_POINTS.items():
if tier <= achieved and pts > best:
best = pts
return best
@router.post("/fasts")
async def start_fast(
req: FastStart,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
# Reject if there's already an active fast
result = await db.execute(
select(Fast).where(Fast.user_id == user.id, Fast.ended_at.is_(None))
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="An active fast already exists — end it first")
f = Fast(
user_id=user.id,
started_at=req.started_at or datetime.now(timezone.utc),
target_hours=req.target_hours,
fast_type=req.fast_type,
notes=req.notes,
)
db.add(f)
await db.flush()
return _fast_to_out(f)
@router.get("/fasts")
async def list_fasts(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = Query(20, le=100),
):
result = await db.execute(
select(Fast).where(Fast.user_id == user.id)
.order_by(Fast.started_at.desc()).limit(limit)
)
return [_fast_to_out(f) for f in result.scalars().all()]
@router.get("/fasts/active")
async def get_active_fast(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Fast).where(Fast.user_id == user.id, Fast.ended_at.is_(None))
.order_by(Fast.started_at.desc()).limit(1)
)
f = result.scalar_one_or_none()
if not f:
return {"active": False}
return {"active": True, **_fast_to_out(f)}
@router.patch("/fasts/{fast_id}")
async def end_fast(
fast_id: str,
req: FastUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Fast).where(Fast.id == uuid.UUID(fast_id), Fast.user_id == user.id)
)
f = result.scalar_one_or_none()
if not f:
raise HTTPException(status_code=404, detail="Fast not found")
if f.ended_at:
raise HTTPException(status_code=400, detail="Fast already ended")
ended = req.ended_at or datetime.now(timezone.utc)
if ended < f.started_at:
raise HTTPException(status_code=400, detail="End time cannot be before start time")
f.ended_at = ended
if req.notes is not None:
f.notes = req.notes
elapsed_hours = (ended - f.started_at).total_seconds() / 3600
f.completed = elapsed_hours >= f.target_hours
f.broken_early = not f.completed
# Award points for whatever tier was achieved (partial credit)
pts = _points_for_fast(f.target_hours, elapsed_hours)
if pts > 0 and f.points_awarded == 0:
activity = await log_activity_with_points(
db=db,
user_id=user.id,
category="fast_completed",
activity_date=ended.date(),
title=f"{f.fast_type} fast — {int(elapsed_hours)}h",
duration_minutes=int(elapsed_hours * 60),
source="nutrition",
metadata={"fast_id": str(f.id), "target_hours": f.target_hours, "elapsed_hours": elapsed_hours},
)
# Override points with our tier-based calc (log_activity used default rule)
activity.points_earned = pts
f.activity_log_id = activity.id
f.points_awarded = pts
await db.flush()
return _fast_to_out(f)
@router.delete("/fasts/{fast_id}")
async def delete_fast(
fast_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Fast).where(Fast.id == uuid.UUID(fast_id), Fast.user_id == user.id)
)
f = result.scalar_one_or_none()
if not f:
raise HTTPException(status_code=404, detail="Fast not found")
await db.delete(f)
return {"status": "deleted"}
# ---------- Electrolytes ----------
@router.post("/electrolytes")
async def log_electrolytes(
req: ElectrolyteIn,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
entry = ElectrolyteLog(
user_id=user.id,
log_date=datetime.now(timezone.utc),
sodium_mg=req.sodium_mg,
potassium_mg=req.potassium_mg,
magnesium_mg=req.magnesium_mg,
notes=req.notes,
)
db.add(entry)
await db.flush()
return {"id": str(entry.id), "status": "logged"}
@router.get("/electrolytes/today")
async def get_electrolytes_today(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
today_start = datetime.combine(date.today(), datetime.min.time()).replace(tzinfo=timezone.utc)
result = await db.execute(
select(
func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0),
func.coalesce(func.sum(ElectrolyteLog.potassium_mg), 0),
func.coalesce(func.sum(ElectrolyteLog.magnesium_mg), 0),
).where(
ElectrolyteLog.user_id == user.id,
ElectrolyteLog.log_date >= today_start,
)
)
sodium, potassium, magnesium = result.one()
return {
"sodium_mg": int(sodium),
"potassium_mg": int(potassium),
"magnesium_mg": int(magnesium),
"targets": {"sodium_mg": 5000, "potassium_mg": 3500, "magnesium_mg": 400},
}

View file

@ -1,142 +0,0 @@
from __future__ import annotations
from typing import Annotated
from decimal import Decimal
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.profile import UserProfile
from app.models.points import DailyTarget, TTM_DAILY_TARGETS
from app.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
from app.utils.auth import get_current_user
from app.services.resource_matcher import get_recommendations
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
@router.get("/status", response_model=OnboardingStatusResponse)
async def onboarding_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
profile = result.scalar_one_or_none()
if not profile:
return OnboardingStatusResponse(phase1_completed=False, phase2_completed=False)
return OnboardingStatusResponse(
phase1_completed=profile.phase1_completed,
phase2_completed=profile.phase2_completed,
primary_goal=profile.primary_goal,
ttm_stage=profile.ttm_stage,
)
@router.post("/phase1")
async def save_phase1(
req: Phase1Request,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
profile = result.scalar_one_or_none()
if not profile:
profile = UserProfile(user_id=user.id)
db.add(profile)
profile.primary_goal = req.primary_goal
profile.ttm_stage = req.ttm_stage
profile.phase1_completed = True
# Adjust daily targets based on TTM stage
targets = TTM_DAILY_TARGETS.get(req.ttm_stage, {})
if targets:
dt_result = await db.execute(select(DailyTarget).where(DailyTarget.user_id == user.id))
dt = dt_result.scalar_one_or_none()
if dt:
dt.daily_minimum_pts = targets.get("daily_minimum_pts", dt.daily_minimum_pts)
dt.weekly_target_pts = targets.get("weekly_target_pts", dt.weekly_target_pts)
await db.flush()
return {"status": "ok", "phase1_completed": True}
@router.post("/phase2")
async def save_phase2(
req: Phase2Request,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(UserProfile).where(UserProfile.user_id == user.id))
profile = result.scalar_one_or_none()
if not profile:
profile = UserProfile(user_id=user.id)
db.add(profile)
# Body metrics
profile.age = req.age
if req.height_cm is not None:
profile.height_cm = Decimal(str(req.height_cm))
if req.weight_kg is not None:
profile.weight_kg = Decimal(str(req.weight_kg))
profile.gender = req.gender
# PAR-Q+
profile.parq_heart_condition = req.parq_heart_condition
profile.parq_joint_issues = req.parq_joint_issues
profile.parq_medications = req.parq_medications
profile.parq_other_conditions = req.parq_other_conditions
profile.parq_cleared = not (req.parq_heart_condition or req.parq_joint_issues or req.parq_medications)
# BREQ-2 motivation
if req.motivation_external is not None:
profile.motivation_external = Decimal(str(req.motivation_external))
if req.motivation_introjected is not None:
profile.motivation_introjected = Decimal(str(req.motivation_introjected))
if req.motivation_identified is not None:
profile.motivation_identified = Decimal(str(req.motivation_identified))
if req.motivation_intrinsic is not None:
profile.motivation_intrinsic = Decimal(str(req.motivation_intrinsic))
if req.motivation_amotivation is not None:
profile.motivation_amotivation = Decimal(str(req.motivation_amotivation))
# Calculate RAI
if all(v is not None for v in [
req.motivation_amotivation, req.motivation_external,
req.motivation_introjected, req.motivation_identified, req.motivation_intrinsic,
]):
rai = (
-3 * req.motivation_amotivation
+ -2 * req.motivation_external
+ -1 * req.motivation_introjected
+ 2 * req.motivation_identified
+ 3 * req.motivation_intrinsic
)
profile.motivation_rai = Decimal(str(round(rai, 1)))
# Preferences
profile.activity_preferences = req.activity_preferences
profile.equipment_access = req.equipment_access
profile.equipment_list = req.equipment_list
profile.days_per_week = req.days_per_week
profile.minutes_per_session = req.minutes_per_session
profile.phase2_completed = True
await db.flush()
parq_warning = None
if not profile.parq_cleared:
parq_warning = "We recommend checking with your doctor before starting a new exercise program."
return {"status": "ok", "phase2_completed": True, "parq_warning": parq_warning}
@router.get("/recommendations")
async def get_resource_recommendations(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
recs = await get_recommendations(db, user.id)
return {"recommendations": recs}

View file

@ -1,149 +0,0 @@
from __future__ import annotations
from typing import Annotated
from datetime import date
import uuid as uuid_mod
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward
from app.schemas.points import PointRuleUpdate, DailyTargetUpdate
from app.schemas.reward import RewardCreate, RewardRedeemRequest
from app.utils.auth import get_current_user
from app.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
router = APIRouter(prefix="/api/points", tags=["points"])
@router.get("/today")
async def today_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
return await get_today_status(db, user.id)
@router.get("/week")
async def week_summary(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
start: date | None = None,
):
d = start or date.today()
return await get_weekly_summary(db, user.id, d)
@router.get("/rules")
async def list_rules(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(PointRule).where(PointRule.user_id == user.id))
rules = result.scalars().all()
return [
{"id": str(r.id), "category": r.category, "description": r.description,
"points": r.points, "unit": r.unit, "is_active": r.is_active}
for r in rules
]
@router.patch("/rules/{rule_id}")
async def update_rule(
rule_id: str,
req: PointRuleUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(PointRule).where(PointRule.id == uuid_mod.UUID(rule_id), PointRule.user_id == user.id)
)
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404)
if req.points is not None:
rule.points = req.points
if req.description is not None:
rule.description = req.description
if req.is_active is not None:
rule.is_active = req.is_active
await db.flush()
return {"status": "updated"}
@router.get("/targets")
async def get_targets(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(DailyTarget).where(DailyTarget.user_id == user.id))
t = result.scalar_one_or_none()
if not t:
return {"daily_minimum_pts": 80, "weekly_target_pts": 500, "weekly_bonus_pts": 50}
return {"daily_minimum_pts": t.daily_minimum_pts, "weekly_target_pts": t.weekly_target_pts, "weekly_bonus_pts": t.weekly_bonus_pts}
@router.patch("/targets")
async def update_targets(
req: DailyTargetUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(DailyTarget).where(DailyTarget.user_id == user.id))
t = result.scalar_one_or_none()
if not t:
raise HTTPException(status_code=404)
if req.daily_minimum_pts is not None:
t.daily_minimum_pts = req.daily_minimum_pts
if req.weekly_target_pts is not None:
t.weekly_target_pts = req.weekly_target_pts
if req.weekly_bonus_pts is not None:
t.weekly_bonus_pts = req.weekly_bonus_pts
await db.flush()
return {"status": "updated"}
# --- Rewards ---
rewards_router = APIRouter(prefix="/api/rewards", tags=["rewards"])
@rewards_router.get("")
async def list_rewards(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(Reward).where(Reward.user_id == user.id).order_by(Reward.created_at))
rewards = result.scalars().all()
return [
{"id": str(r.id), "name": r.name, "description": r.description,
"point_cost": r.point_cost, "is_active": r.is_active}
for r in rewards
]
@rewards_router.post("")
async def create_reward(
req: RewardCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
reward = Reward(user_id=user.id, name=req.name, description=req.description, point_cost=req.point_cost)
db.add(reward)
await db.flush()
return {"id": str(reward.id), "name": reward.name, "point_cost": reward.point_cost}
@rewards_router.post("/{reward_id}/redeem")
async def redeem(
reward_id: str,
req: RewardRedeemRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["reason"])
return result

View file

@ -1,120 +0,0 @@
from __future__ import annotations
from typing import Annotated
from datetime import date, timedelta
import uuid as uuid_mod
from fastapi import APIRouter, Depends, HTTPException
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.program import Program
from app.models.activity import ActivityLog
from app.utils.auth import get_current_user
from pydantic import BaseModel
router = APIRouter(prefix="/api/programs", tags=["programs"])
class ProgramCreate(BaseModel):
name: str
source: str | None = None
source_url: str | None = None
start_date: date
duration_days: int = 90
notes: str | None = None
@router.get("")
async def list_programs(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Program).where(Program.user_id == user.id).order_by(Program.created_at.desc())
)
programs = result.scalars().all()
out = []
for p in programs:
today = date.today()
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1
out.append({
"id": str(p.id), "name": p.name, "source": p.source, "source_url": p.source_url,
"start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat(),
"status": p.status, "current_day": min(day_num, total), "total_days": total,
"catalog_id": str(p.catalog_id) if p.catalog_id else None,
"current_week": p.current_week,
})
return out
@router.post("")
async def create_program(
req: ProgramCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
program = Program(
user_id=user.id,
name=req.name,
source=req.source,
source_url=req.source_url,
start_date=req.start_date,
end_date=req.start_date + timedelta(days=req.duration_days - 1),
notes=req.notes,
)
db.add(program)
await db.flush()
return {"id": str(program.id), "name": program.name, "start_date": program.start_date.isoformat(), "end_date": program.end_date.isoformat()}
@router.get("/{program_id}")
async def get_program(
program_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(
select(Program).where(Program.id == uuid_mod.UUID(program_id), Program.user_id == user.id)
)
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404)
# Count logged days
days_logged = await db.execute(
select(func.count(func.distinct(ActivityLog.activity_date)))
.where(ActivityLog.program_id == p.id)
)
logged = days_logged.scalar() or 0
today = date.today()
day_num = (today - p.start_date).days + 1
total = (p.end_date - p.start_date).days + 1
return {
"id": str(p.id), "name": p.name, "source": p.source, "source_url": p.source_url,
"start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat(),
"status": p.status, "current_day": min(day_num, total), "total_days": total,
"days_logged": logged, "notes": p.notes,
}
@router.patch("/{program_id}")
async def update_program_status(
program_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
status: str = "completed",
):
result = await db.execute(
select(Program).where(Program.id == uuid_mod.UUID(program_id), Program.user_id == user.id)
)
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404)
p.status = status
await db.flush()
return {"status": p.status}

View file

@ -1,431 +0,0 @@
"""Support chat router — user messaging + admin replies + Telegram notifications."""
from __future__ import annotations
import uuid as uuid_mod
from datetime import datetime, date, timezone, timedelta
from typing import Annotated
import httpx
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.support import SupportThread, SupportMessage
from app.models.activity import ActivityLog
from app.services.points_engine import get_active_program, get_today_status
from app.utils.auth import get_current_user
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/support", tags=["support"])
MAX_MESSAGES_PER_DAY = 10
# ── Schemas ─────────────────────────────────────────────────────────────────
class SendMessageRequest(BaseModel):
body: str
class AdminReplyRequest(BaseModel):
body: str
# ── Context Gathering ───────────────────────────────────────────────────────
async def gather_user_context(db: AsyncSession, user: User) -> dict:
"""Collect current user state for support context."""
context = {
"member_since": str(user.created_at.date()) if user.created_at else None,
}
try:
program = await get_active_program(db, user.id)
if program:
context["program_name"] = program.get("name")
context["program_day"] = program.get("day")
context["program_total_days"] = program.get("total_days")
day = program.get("day", 0)
total = program.get("total_days", 1)
context["program_completion_pct"] = round((day / total * 100) if total > 0 else 0, 1)
except Exception:
pass
try:
status = await get_today_status(db, user.id)
context["points_today"] = status.get("points_earned", 0)
context["daily_target"] = status.get("daily_minimum", 80)
context["gate_passed"] = status.get("gate_passed", False)
except Exception:
pass
try:
result = await db.execute(
select(ActivityLog)
.where(ActivityLog.user_id == user.id, ActivityLog.category == "workout")
.order_by(ActivityLog.logged_at.desc())
.limit(1)
)
last = result.scalar_one_or_none()
if last:
context["last_workout"] = f"{last.activity_date}{last.title or 'Workout'}"
except Exception:
pass
return context
# ── Telegram Notification ──────────────────────────────────────────────────
def format_telegram_message(user_name: str, message: str, context: dict) -> str:
"""Format the Telegram notification message."""
lines = [f"🏋️ *Fitness Support*\n"]
lines.append(f"From: *{user_name}*")
prog = context.get("program_name")
if prog:
day = context.get("program_day", "?")
total = context.get("program_total_days", "?")
pct = context.get("program_completion_pct", 0)
lines.append(f"Program: {prog} (Day {day}/{total}{pct}%)")
pts = context.get("points_today", 0)
target = context.get("daily_target", 80)
gate = "" if context.get("gate_passed") else ""
lines.append(f"Points today: {pts}/{target} {gate}")
last = context.get("last_workout")
if last:
lines.append(f"Last workout: {last}")
lines.append(f"\n💬 \"{message}\"")
lines.append(f"\n👉 {settings.base_url}/support/admin")
return "\n".join(lines)
async def send_telegram_notification(user_name: str, message: str, context: dict):
"""Fire-and-forget Telegram notification. Never blocks or fails the request."""
token = settings.telegram_bot_token
chat_id = settings.telegram_chat_id
if not token or not chat_id:
logger.warning("Telegram credentials not configured — skipping notification")
return
text = format_telegram_message(user_name, message, context)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
},
)
if resp.status_code != 200:
logger.warning(f"Telegram send failed: {resp.status_code} {resp.text[:200]}")
except Exception as e:
logger.warning(f"Telegram notification error: {e}")
# ── Helper: Get or Create Thread ───────────────────────────────────────────
async def get_or_create_thread(db: AsyncSession, user_id: uuid_mod.UUID) -> SupportThread:
"""Get the user's support thread, creating one if it doesn't exist."""
result = await db.execute(
select(SupportThread)
.options(selectinload(SupportThread.messages))
.where(SupportThread.user_id == user_id)
)
thread = result.scalar_one_or_none()
if not thread:
thread = SupportThread(user_id=user_id)
db.add(thread)
await db.flush()
# Re-fetch with messages loaded
result = await db.execute(
select(SupportThread)
.options(selectinload(SupportThread.messages))
.where(SupportThread.id == thread.id)
)
thread = result.scalar_one_or_none()
return thread
# ── User Endpoints ─────────────────────────────────────────────────────────
@router.get("/thread")
async def get_thread(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get the user's support thread with all messages. Marks admin replies as read."""
thread = await get_or_create_thread(db, user.id)
# Mark admin messages as read
if thread.unread_user > 0:
now = datetime.now(timezone.utc)
await db.execute(
update(SupportMessage)
.where(
SupportMessage.thread_id == thread.id,
SupportMessage.sender == "admin",
SupportMessage.read_at.is_(None),
)
.values(read_at=now)
)
thread.unread_user = 0
await db.flush()
messages = [
{
"id": str(m.id),
"sender": m.sender,
"body": m.body,
"created_at": m.created_at.isoformat(),
"read_at": m.read_at.isoformat() if m.read_at else None,
}
for m in thread.messages
]
return {
"thread_id": str(thread.id),
"messages": messages,
}
@router.post("/messages")
async def send_message(
req: SendMessageRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""User sends a support message. Auto-attaches context, fires Telegram notification."""
body = req.body.strip()
if not body:
raise HTTPException(status_code=400, detail="Message cannot be empty")
if len(body) > 2000:
raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)")
# Rate limit check
today_start = datetime.combine(date.today(), datetime.min.time(), tzinfo=timezone.utc)
thread = await get_or_create_thread(db, user.id)
result = await db.execute(
select(func.count(SupportMessage.id)).where(
SupportMessage.thread_id == thread.id,
SupportMessage.sender == "user",
SupportMessage.created_at >= today_start,
)
)
count_today = result.scalar() or 0
if count_today >= MAX_MESSAGES_PER_DAY:
raise HTTPException(
status_code=429,
detail=f"Maximum {MAX_MESSAGES_PER_DAY} messages per day. Try again tomorrow."
)
# Gather context
context = await gather_user_context(db, user)
# Create message
message = SupportMessage(
thread_id=thread.id,
sender="user",
body=body,
context=context,
)
db.add(message)
thread.last_message_at = datetime.now(timezone.utc)
thread.unread_admin += 1
await db.flush()
# Fire Telegram notification (non-blocking)
display_name = getattr(user, "display_name", None) or user.username
await send_telegram_notification(display_name, body, context)
return {
"id": str(message.id),
"status": "sent",
"message": "Message sent! We'll get back to you soon.",
}
@router.get("/unread")
async def get_unread_count(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get unread count for the user (admin replies they haven't seen)."""
result = await db.execute(
select(SupportThread.unread_user).where(SupportThread.user_id == user.id)
)
count = result.scalar()
return {"unread": count or 0}
# ── Admin Endpoints ────────────────────────────────────────────────────────
def require_admin(user: User):
"""Check that the user has admin privileges."""
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin access required")
@router.get("/admin/threads")
async def list_threads(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all support threads with last message preview. Admin only."""
require_admin(user)
result = await db.execute(
select(SupportThread)
.options(selectinload(SupportThread.messages))
.order_by(SupportThread.last_message_at.desc())
)
threads = result.scalars().all()
out = []
for t in threads:
# Get the user's display name
from app.models.user import User as UserModel
user_result = await db.execute(
select(UserModel).where(UserModel.id == t.user_id)
)
thread_user = user_result.scalar_one_or_none()
user_name = thread_user.display_name or thread_user.username if thread_user else "Unknown"
last_msg = t.messages[-1] if t.messages else None
out.append({
"id": str(t.id),
"user_id": str(t.user_id),
"user_name": user_name,
"unread_admin": t.unread_admin,
"last_message": {
"sender": last_msg.sender,
"body": last_msg.body[:100] + ("..." if len(last_msg.body) > 100 else ""),
"created_at": last_msg.created_at.isoformat(),
} if last_msg else None,
"message_count": len(t.messages),
})
return out
@router.get("/admin/threads/{thread_id}")
async def get_admin_thread(
thread_id: str,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get full thread with messages and context. Marks user messages as read. Admin only."""
require_admin(user)
result = await db.execute(
select(SupportThread)
.options(selectinload(SupportThread.messages))
.where(SupportThread.id == uuid_mod.UUID(thread_id))
)
thread = result.scalar_one_or_none()
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
# Mark user messages as read
if thread.unread_admin > 0:
now = datetime.now(timezone.utc)
await db.execute(
update(SupportMessage)
.where(
SupportMessage.thread_id == thread.id,
SupportMessage.sender == "user",
SupportMessage.read_at.is_(None),
)
.values(read_at=now)
)
thread.unread_admin = 0
await db.flush()
# Get user info
from app.models.user import User as UserModel
user_result = await db.execute(
select(UserModel).where(UserModel.id == thread.user_id)
)
thread_user = user_result.scalar_one_or_none()
# Get latest context from the most recent user message
latest_context = None
for m in reversed(thread.messages):
if m.sender == "user" and m.context:
latest_context = m.context
break
messages = [
{
"id": str(m.id),
"sender": m.sender,
"body": m.body,
"context": m.context if m.sender == "user" else None,
"created_at": m.created_at.isoformat(),
"read_at": m.read_at.isoformat() if m.read_at else None,
}
for m in thread.messages
]
return {
"thread_id": str(thread.id),
"user_name": thread_user.display_name or thread_user.username if thread_user else "Unknown",
"user_id": str(thread.user_id),
"latest_context": latest_context,
"messages": messages,
}
@router.post("/admin/threads/{thread_id}/reply")
async def reply_to_thread(
thread_id: str,
req: AdminReplyRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Admin sends a reply to a user's support thread."""
require_admin(user)
body = req.body.strip()
if not body:
raise HTTPException(status_code=400, detail="Reply cannot be empty")
result = await db.execute(
select(SupportThread).where(SupportThread.id == uuid_mod.UUID(thread_id))
)
thread = result.scalar_one_or_none()
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
message = SupportMessage(
thread_id=thread.id,
sender="admin",
body=body,
)
db.add(message)
thread.last_message_at = datetime.now(timezone.utc)
thread.unread_user += 1
await db.flush()
return {
"id": str(message.id),
"status": "sent",
}

View file

@ -1,32 +0,0 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
class ActivityCreate(BaseModel):
category: str # workout, food_log, steps_target, screen_free, daily_checkin
title: str | None = None
description: str | None = None
duration_minutes: int | None = None
activity_date: date
program_id: str | None = None
program_day: int | None = None
metadata: dict = {}
class ActivityResponse(BaseModel):
id: str
category: str
title: str | None
description: str | None
duration_minutes: int | None
source: str
points_earned: int
activity_date: date
logged_at: datetime
program_id: str | None
program_day: int | None
metadata_json: dict
model_config = {"from_attributes": True}

View file

@ -1,30 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=128)
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
email: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: str
username: str
display_name: str
email: str | None
timezone: str
model_config = {"from_attributes": True}

View file

@ -1,55 +0,0 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
class FoodCreate(BaseModel):
meal_type: str # breakfast, lunch, dinner, snack
food_name: str
brand: str | None = None
barcode: str | None = None
serving_size: str | None = None
servings: float = 1.0
calories: float | None = None
protein_g: float | None = None
carbs_g: float | None = None
fat_g: float | None = None
fiber_g: float | None = None
sodium_mg: float | None = None
sugar_g: float | None = None
food_date: date
off_product_id: str | None = None
off_data: dict = {}
class FoodResponse(BaseModel):
id: str
meal_type: str
food_name: str
brand: str | None
barcode: str | None
serving_size: str | None
servings: float
calories: float | None
protein_g: float | None
carbs_g: float | None
fat_g: float | None
food_date: date
logged_at: datetime
model_config = {"from_attributes": True}
class FoodSearchResult(BaseModel):
barcode: str | None = None
product_name: str | None = None
brand: str | None = None
calories_100g: float | None = None
protein_100g: float | None = None
carbs_100g: float | None = None
fat_100g: float | None = None
fiber_100g: float | None = None
sugar_100g: float | None = None
serving_size: str | None = None
image_url: str | None = None

View file

@ -1,67 +0,0 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class NutritionGoalIn(BaseModel):
calorie_target: int | None = None
protein_g_target: int | None = None
fat_g_target: int | None = None
net_carbs_g_cap: int | None = None
training_calorie_target: int | None = None
training_protein_g_target: int | None = None
training_fat_g_target: int | None = None
eating_window_start_hour: int | None = None
eating_window_end_hour: int | None = None
default_fast_hours: int | None = None
diet_style: str | None = None
timezone_str: str | None = None
class NutritionGoalOut(BaseModel):
id: str
calorie_target: int
protein_g_target: int
fat_g_target: int
net_carbs_g_cap: int
training_calorie_target: int | None
training_protein_g_target: int | None
training_fat_g_target: int | None
eating_window_start_hour: int
eating_window_end_hour: int
default_fast_hours: int
diet_style: str
timezone_str: str
class FastStart(BaseModel):
target_hours: int
fast_type: str = "daily" # daily, weekly_24, long_48, long_72, extended
started_at: datetime | None = None # defaults to now if omitted
notes: str | None = None
class FastUpdate(BaseModel):
ended_at: datetime | None = None # defaults to now if omitted, terminates the fast
notes: str | None = None
class FastOut(BaseModel):
id: str
started_at: datetime
ended_at: datetime | None
target_hours: int
fast_type: str
completed: bool
broken_early: bool
points_awarded: int
elapsed_hours: float
notes: str | None
class ElectrolyteIn(BaseModel):
sodium_mg: int = 0
potassium_mg: int = 0
magnesium_mg: int = 0
notes: str | None = None

View file

@ -1,45 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel
class Phase1Request(BaseModel):
primary_goal: str # lose_weight, build_strength, get_active, feel_better
ttm_stage: str # precontemplation, contemplation, preparation, action, maintenance
class Phase2Request(BaseModel):
# Body metrics (optional)
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
gender: str | None = None
# PAR-Q+
parq_heart_condition: bool = False
parq_joint_issues: bool = False
parq_medications: bool = False
parq_other_conditions: str | None = None
# BREQ-2 motivation (1-5 scale)
motivation_external: float | None = None
motivation_introjected: float | None = None
motivation_identified: float | None = None
motivation_intrinsic: float | None = None
motivation_amotivation: float | None = None
# Preferences
activity_preferences: list[str] = []
equipment_access: str | None = None # legacy single value
equipment_list: list[str] = [] # new: list of equipment tags
days_per_week: int = 3
minutes_per_session: int = 30
class OnboardingStatusResponse(BaseModel):
phase1_completed: bool
phase2_completed: bool
primary_goal: str | None = None
ttm_stage: str | None = None
model_config = {"from_attributes": True}

View file

@ -1,71 +0,0 @@
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
class PointRuleResponse(BaseModel):
id: str
category: str
description: str
points: int
unit: str | None
is_active: bool
model_config = {"from_attributes": True}
class PointRuleUpdate(BaseModel):
points: int | None = None
description: str | None = None
is_active: bool | None = None
class DailyTargetResponse(BaseModel):
daily_minimum_pts: int
weekly_target_pts: int
weekly_bonus_pts: int
model_config = {"from_attributes": True}
class DailyTargetUpdate(BaseModel):
daily_minimum_pts: int | None = None
weekly_target_pts: int | None = None
weekly_bonus_pts: int | None = None
class DailySummary(BaseModel):
date: date
points_earned: int
points_spent: int
gate_passed: bool
daily_minimum: int
activities: list[dict] = []
class WeeklySummary(BaseModel):
week_start: date
week_end: date
total_points_earned: int
total_points_spent: int
active_days: int
gate_passed_days: int
hit_weekly_target: bool
weekly_target: int
weekly_bonus_earned: int
daily_breakdown: list[DailySummary] = []
class TodayStatus(BaseModel):
date: date
points_earned: int
daily_minimum: int
gate_passed: bool
points_remaining: int
activities_today: list[dict] = []
rewards_available: list[dict] = []
week_points: int
weekly_target: int
program_day: int | None = None
program_total_days: int | None = None

View file

@ -1,32 +0,0 @@
from __future__ import annotations
from datetime import date
from typing import Optional
from pydantic import BaseModel, Field
class RewardCreate(BaseModel):
name: str
description: Optional[str] = None
point_cost: int
class RewardResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
point_cost: int
is_active: bool
model_config = {"from_attributes": True}
class RewardRedeemRequest(BaseModel):
redemption_date: Optional[date] = Field(None, alias="date")
class RedemptionResponse(BaseModel):
id: str
reward_name: str
points_spent: int
redemption_date: date

View file

@ -1,328 +0,0 @@
"""Multi-provider AI coaching service.
Two code paths:
- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom
- Anthropic: covers Claude (different message format + auth header)
- Gemini: thin adapter for Google's generateContent API
System prompt is built from AGENT_GUIDE.md + live user context.
Chat history is ephemeral (not persisted).
"""
from __future__ import annotations
import json
import logging
from typing import AsyncGenerator
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.services.crypto import decrypt_value
from app.models.integration_config import IntegrationConfig
logger = logging.getLogger(__name__)
settings = get_settings()
# AI provider presets — base_url and api_format for each named provider
AI_PRESETS = {
"openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"},
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"},
"huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"},
"groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"},
"ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"},
"claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"},
"gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"},
"custom_ai": {"base_url": "", "format": "openai", "default_model": ""},
}
# System prompt template — {context} is replaced with live user data
SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform.
Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses.
CURRENT USER CONTEXT:
{context}
RULES:
- Be encouraging but honest. Celebrate progress, address setbacks constructively.
- When the user asks to log a workout or food, confirm what you'll log and do it.
- Reference their program schedule when suggesting workouts.
- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone.
- Keep responses concise this is a mobile chat, not an essay.
- If the user hasn't earned enough points today, gently encourage activity.
- Never fabricate data only reference what's in the context."""
async def get_active_ai_provider(db: AsyncSession, user_id=None) -> dict | None:
"""Find the first configured AI provider for the given user."""
query = select(IntegrationConfig).where(
IntegrationConfig.provider.in_(list(AI_PRESETS.keys()))
)
if user_id is not None:
query = query.where(IntegrationConfig.user_id == user_id)
result = await db.execute(query)
rows = result.scalars().all()
# Group rows by provider: {"gemini": {"api_key": "decrypted_value"}}
providers: dict[str, dict[str, str]] = {}
for row in rows:
if row.provider not in providers:
providers[row.provider] = {}
try:
providers[row.provider][row.config_key] = decrypt_value(
settings.secret_key, row.config_value
)
except Exception:
continue
# Return the first provider that has at least one credential
for provider_name, creds in providers.items():
if not creds:
continue
preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"])
api_key = creds.get("api_key", "")
base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"]
model = creds.get("model", preset["default_model"]) or preset["default_model"]
return {
"name": provider_name,
"format": preset["format"],
"base_url": base_url,
"api_key": api_key,
"model": model,
}
return None
async def build_context(db: AsyncSession, user_id) -> str:
"""Build live context string from user's current state."""
from app.models.user import User
from app.models.profile import UserProfile
context_parts = []
# User profile
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user:
context_parts.append(f"Name: {user.display_name}")
context_parts.append(f"Timezone: {user.timezone}")
# Profile / motivation
profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none()
if profile:
if profile.ttm_stage:
context_parts.append(f"Stage of change: {profile.ttm_stage}")
if profile.breq_profile:
context_parts.append(f"Motivation type: {profile.breq_profile}")
# Today's points (best effort)
try:
from app.services.points_engine import get_daily_summary
summary = await get_daily_summary(db, user_id)
context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
except Exception:
pass
return "\n".join(context_parts) if context_parts else "No profile data available yet."
async def chat(
user_id,
message: str,
history: list[dict],
db: AsyncSession,
) -> AsyncGenerator[str, None]:
"""Route chat to the configured AI provider with streaming."""
provider = await get_active_ai_provider(db, user_id=user_id)
if not provider:
yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)."
return
context = await build_context(db, user_id)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context)
try:
if provider["format"] == "openai":
async for chunk in _call_openai_compat(
base_url=provider["base_url"],
api_key=provider["api_key"],
model=provider["model"],
system_prompt=system_prompt,
history=history,
message=message,
):
yield chunk
elif provider["format"] == "anthropic":
async for chunk in _call_anthropic(
api_key=provider["api_key"],
model=provider["model"],
system_prompt=system_prompt,
history=history,
message=message,
):
yield chunk
elif provider["format"] == "gemini":
async for chunk in _call_gemini(
api_key=provider["api_key"],
model=provider["model"],
system_prompt=system_prompt,
history=history,
message=message,
):
yield chunk
except httpx.HTTPStatusError as e:
yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations."
except httpx.ConnectError:
yield f"Cannot reach {provider['name']}. Check your network or endpoint URL."
except Exception as e:
logger.error(f"AI chat error: {e}")
yield "Something went wrong with the AI provider. Check Settings → Integrations."
async def _call_openai_compat(
base_url: str,
api_key: str,
model: str,
system_prompt: str,
history: list[dict],
message: str,
) -> AsyncGenerator[str, None]:
"""Call any OpenAI-compatible /v1/chat/completions endpoint with streaming.
Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom.
"""
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
*history,
{"role": "user", "content": message},
],
"stream": True,
"max_tokens": 1024,
}
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
f"{base_url.rstrip('/')}/chat/completions",
json=payload,
headers=headers,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: ") and line.strip() != "data: [DONE]":
try:
chunk = json.loads(line[6:])
delta = chunk.get("choices", [{}])[0].get("delta", {})
if content := delta.get("content"):
yield content
except (json.JSONDecodeError, IndexError, KeyError):
continue
async def _call_anthropic(
api_key: str,
model: str,
system_prompt: str,
history: list[dict],
message: str,
) -> AsyncGenerator[str, None]:
"""Call Anthropic's /v1/messages endpoint with streaming."""
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
# Convert OpenAI-style history to Anthropic format
messages = []
for msg in history:
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": message})
payload = {
"model": model,
"system": system_prompt,
"messages": messages,
"max_tokens": 1024,
"stream": True,
}
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
"https://api.anthropic.com/v1/messages",
json=payload,
headers=headers,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
try:
event = json.loads(line[6:])
if event.get("type") == "content_block_delta":
delta = event.get("delta", {})
if text := delta.get("text"):
yield text
except (json.JSONDecodeError, KeyError):
continue
async def _call_gemini(
api_key: str,
model: str,
system_prompt: str,
history: list[dict],
message: str,
) -> AsyncGenerator[str, None]:
"""Call Google Gemini's generateContent endpoint with streaming."""
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
# Build Gemini content format
contents = []
# System instruction goes as first user/model exchange
if system_prompt:
contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]})
contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]})
for msg in history:
role = "model" if msg["role"] == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": msg["content"]}]})
contents.append({"role": "user", "parts": [{"text": message}]})
payload = {
"contents": contents,
"generationConfig": {"maxOutputTokens": 1024},
}
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
url,
json=payload,
params={"key": api_key, "alt": "sse"},
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
try:
chunk = json.loads(line[6:])
candidates = chunk.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if text := part.get("text"):
yield text
except (json.JSONDecodeError, KeyError):
continue

View file

@ -1,90 +0,0 @@
"""Crawl queue scheduler — processes jobs respecting priority and time constraints."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session
from app.models.catalog import CrawlQueue
from app.services.program_research import process_crawl_job
logger = logging.getLogger(__name__)
# ICT = UTC+7. Off-peak: 22:00-06:00 ICT = 15:00-23:00 UTC
OFFPEAK_START_UTC = 15 # 22:00 ICT
OFFPEAK_END_UTC = 23 # 06:00 ICT
def is_offpeak() -> bool:
"""Check if current time is off-peak (22:00-06:00 ICT)."""
hour = datetime.now(timezone.utc).hour
return hour >= OFFPEAK_START_UTC or hour < OFFPEAK_END_UTC
def is_weekend() -> bool:
"""Check if today is Saturday (5) or Sunday (6)."""
return datetime.now(timezone.utc).weekday() >= 5
async def has_higher_priority_jobs(db: AsyncSession) -> bool:
"""Check if any HIGH or MEDIUM priority jobs are pending."""
result = await db.execute(
select(func.count(CrawlQueue.id)).where(
CrawlQueue.priority.in_(["high", "medium"]),
CrawlQueue.status == "pending",
)
)
return (result.scalar() or 0) > 0
async def get_next_pending_job(db: AsyncSession) -> CrawlQueue | None:
"""Get the oldest pending LOW priority crawl job."""
result = await db.execute(
select(CrawlQueue)
.where(CrawlQueue.status == "pending", CrawlQueue.priority == "low")
.order_by(CrawlQueue.created_at.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def run_queue_cycle() -> None:
"""Process one job from the queue if conditions are met."""
# Only run during off-peak or weekends
if not is_offpeak() and not is_weekend():
return
async with async_session() as db:
try:
# Defer to higher priority work
if await has_higher_priority_jobs(db):
logger.debug("Higher priority jobs pending — skipping fitness crawl")
return
job = await get_next_pending_job(db)
if not job:
return
logger.info(f"Processing crawl job {job.id}: {job.search_query}")
await process_crawl_job(db, job)
await db.commit()
logger.info(f"Crawl job {job.id} completed with status: {job.status}")
except Exception as e:
logger.error(f"Crawl queue cycle error: {e}")
await db.rollback()
async def crawl_queue_loop() -> None:
"""Background loop — checks queue every 5 minutes."""
logger.info("Crawl queue scheduler started")
while True:
try:
await run_queue_cycle()
except Exception as e:
logger.error(f"Crawl queue loop error: {e}")
await asyncio.sleep(300) # 5 minutes

View file

@ -1,42 +0,0 @@
"""Fernet encryption for integration credentials.
Derives a Fernet-compatible key from the app SECRET_KEY using HKDF.
The SECRET_KEY can be any format (hex string, random bytes, passphrase).
"""
from __future__ import annotations
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
import base64
_fernet_instance: Fernet | None = None
def _derive_fernet_key(secret_key: str) -> bytes:
"""Derive a Fernet-compatible key from the app SECRET_KEY."""
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b"diligence-integration-encryption",
info=b"fernet-key",
)
raw = hkdf.derive(secret_key.encode())
return base64.urlsafe_b64encode(raw)
def get_fernet(secret_key: str) -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(_derive_fernet_key(secret_key))
return _fernet_instance
def encrypt_value(secret_key: str, plaintext: str) -> str:
"""Encrypt a plaintext string. Returns base64-encoded ciphertext."""
return get_fernet(secret_key).encrypt(plaintext.encode()).decode()
def decrypt_value(secret_key: str, ciphertext: str) -> str:
"""Decrypt a ciphertext string. Returns plaintext."""
return get_fernet(secret_key).decrypt(ciphertext.encode()).decode()

View file

@ -1,179 +0,0 @@
"""Base class for device activity sync services.
All device sync implementations (garmin_sync, whoop_sync, oura_sync)
inherit from this class. The pattern mirrors the existing strava_sync
and polar_sync services but adds webhook support and a standard
interface for the generic webhook receiver endpoint.
"""
from __future__ import annotations
import abc
import uuid
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.oauth import OAuthToken
from app.models.integration_config import IntegrationConfig
from app.services.crypto import decrypt_value
from app.services.points_engine import log_activity_with_points
logger = logging.getLogger(__name__)
settings = get_settings()
class DeviceSyncBase(abc.ABC):
"""Abstract base for all device sync services.
Subclasses implement the provider-specific OAuth flow, API calls,
and data mapping. The base handles token storage/refresh patterns
and the common activity logging pipeline.
"""
PROVIDER: str # e.g. "garmin", "whoop", "oura"
API_BASE: str # e.g. "https://apis.garmin.com"
# ── OAuth ──
@abc.abstractmethod
async def get_auth_url(self, user_id: uuid.UUID, db: AsyncSession) -> str:
"""Generate the OAuth authorization URL for this provider."""
@abc.abstractmethod
async def handle_callback(
self, code: str, state: str, db: AsyncSession
) -> dict:
"""Exchange authorization code for tokens, store them.
Returns: {"success": True, "provider": "garmin"}
"""
@abc.abstractmethod
async def _refresh_token(self, token: OAuthToken, db: AsyncSession) -> OAuthToken:
"""Provider-specific token refresh logic."""
# ── Sync ──
@abc.abstractmethod
async def sync_activities(
self, user_id: uuid.UUID, db: AsyncSession
) -> list[dict]:
"""Pull new activities from the provider and award points.
Returns list of imported activity dicts with points_earned.
"""
# ── Webhooks ──
@abc.abstractmethod
async def validate_webhook(self, request_body: bytes, headers: dict) -> bool:
"""Validate an incoming webhook signature.
Returns True if the webhook is authentic.
"""
@abc.abstractmethod
async def handle_webhook(
self, payload: dict, db: AsyncSession
) -> dict:
"""Process an incoming webhook notification.
Typically identifies the user and triggers sync_activities().
Returns: {"processed": True, "activities_imported": N}
"""
# ── Helpers (shared) ──
async def get_valid_token(
self, db: AsyncSession, user_id: uuid.UUID
) -> OAuthToken | None:
"""Get a valid OAuth token, refreshing if expired."""
result = await db.execute(
select(OAuthToken).where(
OAuthToken.user_id == user_id,
OAuthToken.provider == self.PROVIDER,
)
)
token = result.scalar_one_or_none()
if not token:
return None
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
try:
token = await self._refresh_token(token, db)
except Exception as e:
logger.error(f"Token refresh failed for {self.PROVIDER}: {e}")
return None
return token
async def get_credentials(self, db: AsyncSession) -> dict | None:
"""Get decrypted OAuth credentials from integration_configs."""
import json
result = await db.execute(
select(IntegrationConfig).where(
IntegrationConfig.provider == self.PROVIDER
)
)
config = result.scalar_one_or_none()
if not config:
return None
try:
return json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
except Exception as e:
logger.error(f"Failed to decrypt {self.PROVIDER} credentials: {e}")
return None
async def import_activity(
self,
db: AsyncSession,
user_id: uuid.UUID,
*,
title: str,
category: str = "workout",
activity_date: Any,
duration_minutes: int | None = None,
external_id: str | None = None,
metadata: dict | None = None,
) -> dict:
"""Import a single activity using the standard points pipeline.
Deduplicates by (user_id, provider, external_id).
"""
from app.models.activity import ActivityLog
if external_id:
existing = await db.execute(
select(ActivityLog).where(
ActivityLog.user_id == user_id,
ActivityLog.source == self.PROVIDER,
ActivityLog.external_id == external_id,
)
)
if existing.scalar_one_or_none():
return {"skipped": True, "external_id": external_id}
entry = await log_activity_with_points(
db=db,
user_id=user_id,
category=category,
activity_date=activity_date,
title=title,
duration_minutes=duration_minutes,
source=self.PROVIDER,
external_id=external_id,
metadata=metadata or {},
)
return {
"id": str(entry.id),
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": str(activity_date),
}

View file

@ -1,78 +0,0 @@
from __future__ import annotations
"""Open Food Facts API client for barcode scanning and food search."""
import httpx
from app.schemas.food import FoodSearchResult
OFF_BASE = "https://world.openfoodfacts.org"
USER_AGENT = "Diligence/1.0"
async def lookup_barcode(barcode: str) -> FoodSearchResult | None:
"""Lookup a product by barcode from Open Food Facts."""
url = f"{OFF_BASE}/api/v2/product/{barcode}.json"
params = {"fields": "product_name,brands,nutriments,serving_size,image_front_small_url"}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params, headers={"User-Agent": USER_AGENT})
if resp.status_code != 200:
return None
data = resp.json()
if data.get("status") != 1 or "product" not in data:
return None
p = data["product"]
n = p.get("nutriments", {})
return FoodSearchResult(
barcode=barcode,
product_name=p.get("product_name"),
brand=p.get("brands"),
calories_100g=n.get("energy-kcal_100g"),
protein_100g=n.get("proteins_100g"),
carbs_100g=n.get("carbohydrates_100g"),
fat_100g=n.get("fat_100g"),
fiber_100g=n.get("fiber_100g"),
sugar_100g=n.get("sugars_100g"),
serving_size=p.get("serving_size"),
image_url=p.get("image_front_small_url"),
)
async def search_food(query: str, page: int = 1) -> list[FoodSearchResult]:
"""Search for food products by name."""
url = f"{OFF_BASE}/cgi/search.pl"
params = {
"search_terms": query,
"search_simple": 1,
"action": "process",
"json": 1,
"page_size": 10,
"page": page,
"fields": "code,product_name,brands,nutriments,serving_size,image_front_small_url",
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params, headers={"User-Agent": USER_AGENT})
if resp.status_code != 200:
return []
data = resp.json()
results = []
for p in data.get("products", []):
n = p.get("nutriments", {})
results.append(FoodSearchResult(
barcode=p.get("code"),
product_name=p.get("product_name"),
brand=p.get("brands"),
calories_100g=n.get("energy-kcal_100g"),
protein_100g=n.get("proteins_100g"),
carbs_100g=n.get("carbohydrates_100g"),
fat_100g=n.get("fat_100g"),
fiber_100g=n.get("fiber_100g"),
sugar_100g=n.get("sugars_100g"),
serving_size=p.get("serving_size"),
image_url=p.get("image_front_small_url"),
))
return results

View file

@ -1,271 +0,0 @@
from __future__ import annotations
"""Points calculation engine — the heart of the reward system."""
import uuid
from datetime import date, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.activity import ActivityLog
from app.models.points import PointRule, DailyTarget
from app.models.reward import Reward, RewardRedemption
from app.models.program import Program
from app.utils.dates import get_week_boundaries
async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
result = await db.execute(
select(func.coalesce(func.sum(ActivityLog.points_earned), 0))
.where(ActivityLog.user_id == user_id, ActivityLog.activity_date == d)
)
return result.scalar()
async def get_daily_points_spent(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
result = await db.execute(
select(func.coalesce(func.sum(RewardRedemption.points_spent), 0))
.where(RewardRedemption.user_id == user_id, RewardRedemption.redemption_date == d)
)
return result.scalar()
async def get_daily_target(db: AsyncSession, user_id: uuid.UUID) -> DailyTarget | None:
result = await db.execute(
select(DailyTarget).where(DailyTarget.user_id == user_id, DailyTarget.is_active == True)
)
return result.scalar_one_or_none()
async def get_week_points(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
mon, sun = get_week_boundaries(d)
result = await db.execute(
select(func.coalesce(func.sum(ActivityLog.points_earned), 0))
.where(
ActivityLog.user_id == user_id,
ActivityLog.activity_date >= mon,
ActivityLog.activity_date <= sun,
)
)
return result.scalar()
async def get_activities_for_date(db: AsyncSession, user_id: uuid.UUID, d: date) -> list[dict]:
result = await db.execute(
select(ActivityLog)
.where(ActivityLog.user_id == user_id, ActivityLog.activity_date == d)
.order_by(ActivityLog.logged_at.desc())
)
activities = result.scalars().all()
return [
{
"id": str(a.id),
"category": a.category,
"title": a.title,
"points_earned": a.points_earned,
"duration_minutes": a.duration_minutes,
"source": a.source,
"logged_at": a.logged_at.isoformat() if a.logged_at else None,
}
for a in activities
]
async def get_available_rewards(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
result = await db.execute(
select(Reward).where(Reward.user_id == user_id, Reward.is_active == True)
)
rewards = result.scalars().all()
return [
{"id": str(r.id), "name": r.name, "point_cost": r.point_cost, "description": r.description}
for r in rewards
]
async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
result = await db.execute(
select(Program)
.where(Program.user_id == user_id, Program.status == "active")
.order_by(Program.created_at.desc())
)
program = result.scalar_one_or_none()
if not program:
return None
today = date.today()
day_num = (today - program.start_date).days + 1
total = (program.end_date - program.start_date).days + 1
return {
"id": str(program.id),
"name": program.name,
"day": min(day_num, total),
"total_days": total,
"start_date": program.start_date.isoformat(),
"end_date": program.end_date.isoformat(),
"status": program.status,
}
async def get_today_status(db: AsyncSession, user_id: uuid.UUID) -> dict:
today = date.today()
earned = await get_daily_points_earned(db, user_id, today)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
weekly_tgt = target.weekly_target_pts if target else 500
gate_passed = earned >= daily_min
activities = await get_activities_for_date(db, user_id, today)
rewards = await get_available_rewards(db, user_id) if gate_passed else []
week_pts = await get_week_points(db, user_id, today)
program = await get_active_program(db, user_id)
return {
"date": today.isoformat(),
"points_earned": earned,
"daily_minimum": daily_min,
"gate_passed": gate_passed,
"points_remaining": max(0, daily_min - earned),
"activities_today": activities,
"rewards_available": rewards,
"week_points": week_pts,
"weekly_target": weekly_tgt,
"program_id": program["id"] if program else None,
"program_day": program["day"] if program else None,
"program_total_days": program["total_days"] if program else None,
"program_name": program["name"] if program else None,
}
async def log_activity_with_points(
db: AsyncSession,
user_id: uuid.UUID,
category: str,
activity_date: date,
title: str | None = None,
description: str | None = None,
duration_minutes: int | None = None,
source: str = "manual",
external_id: str | None = None,
program_id: uuid.UUID | None = None,
program_day: int | None = None,
metadata: dict | None = None,
) -> ActivityLog:
"""Log an activity and auto-calculate points from active rules."""
# Find matching point rule
result = await db.execute(
select(PointRule).where(
PointRule.user_id == user_id,
PointRule.category == category,
PointRule.is_active == True,
)
)
rule = result.scalar_one_or_none()
points = 0
rule_id = None
if rule:
rule_id = rule.id
if rule.unit == "per_hour" and duration_minutes:
points = rule.points * (duration_minutes / 60)
else:
points = rule.points
activity = ActivityLog(
user_id=user_id,
category=category,
title=title,
description=description,
duration_minutes=duration_minutes,
source=source,
external_id=external_id,
points_earned=int(points),
rule_id=rule_id,
activity_date=activity_date,
program_id=program_id,
program_day=program_day,
metadata_json=metadata or {},
)
db.add(activity)
await db.flush()
return activity
async def redeem_reward(
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
) -> dict:
"""Attempt to redeem a reward. Returns success/failure with details."""
d = redemption_date or date.today()
earned = await get_daily_points_earned(db, user_id, d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
if earned < daily_min:
return {"success": False, "reason": f"Need {daily_min} points today, have {earned}"}
result = await db.execute(select(Reward).where(Reward.id == reward_id, Reward.user_id == user_id))
reward = result.scalar_one_or_none()
if not reward:
return {"success": False, "reason": "Reward not found"}
redemption = RewardRedemption(
user_id=user_id,
reward_id=reward_id,
points_spent=reward.point_cost,
redemption_date=d,
)
db.add(redemption)
await db.flush()
return {
"success": True,
"id": str(redemption.id),
"reward_name": reward.name,
"points_spent": reward.point_cost,
"redemption_date": d.isoformat(),
}
async def get_weekly_summary(db: AsyncSession, user_id: uuid.UUID, d: date) -> dict:
"""Generate weekly summary for the week containing date d."""
mon, sun = get_week_boundaries(d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
weekly_tgt = target.weekly_target_pts if target else 500
bonus = target.weekly_bonus_pts if target else 50
daily_breakdown = []
total_earned = 0
total_spent = 0
active_days = 0
gate_days = 0
for i in range(7):
day = mon + timedelta(days=i)
earned = await get_daily_points_earned(db, user_id, day)
spent = await get_daily_points_spent(db, user_id, day)
gate = earned >= daily_min
total_earned += earned
total_spent += spent
if earned > 0:
active_days += 1
if gate:
gate_days += 1
daily_breakdown.append({
"date": day.isoformat(),
"points_earned": earned,
"points_spent": spent,
"gate_passed": gate,
"daily_minimum": daily_min,
})
hit_target = total_earned >= weekly_tgt
return {
"week_start": mon.isoformat(),
"week_end": sun.isoformat(),
"total_points_earned": total_earned,
"total_points_spent": total_spent,
"active_days": active_days,
"gate_passed_days": gate_days,
"hit_weekly_target": hit_target,
"weekly_target": weekly_tgt,
"weekly_bonus_earned": bonus if hit_target else 0,
"daily_breakdown": daily_breakdown,
}

View file

@ -1,147 +0,0 @@
from __future__ import annotations
"""Polar AccessLink API client for activity sync."""
import uuid
from datetime import datetime, timezone
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.oauth import OAuthToken
from app.models.activity import ActivityLog
from app.services.points_engine import log_activity_with_points
settings = get_settings()
POLAR_AUTH_URL = "https://flow.polar.com/oauth2/authorization"
POLAR_TOKEN_URL = "https://polarremote.com/v2/oauth2/token"
POLAR_API = "https://www.polaraccesslink.com/v3"
def get_polar_auth_url(user_id: str) -> str:
return (
f"{POLAR_AUTH_URL}?response_type=code"
f"&client_id={settings.polar_client_id}"
f"&redirect_uri={settings.base_url}/api/integrations/polar/callback"
f"&state={user_id}"
)
async def exchange_polar_code(code: str) -> dict:
import base64
creds = base64.b64encode(f"{settings.polar_client_id}:{settings.polar_client_secret}".encode()).decode()
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(POLAR_TOKEN_URL, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": f"{settings.base_url}/api/integrations/polar/callback",
}, headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded",
})
resp.raise_for_status()
return resp.json()
async def register_polar_user(access_token: str) -> dict | None:
"""Register user with Polar AccessLink (required before pulling data)."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{POLAR_API}/users",
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"member-id": "fitness-rewards-user"},
)
if resp.status_code in (200, 409): # 409 = already registered
return resp.json() if resp.status_code == 200 else {"status": "already_registered"}
return None
async def sync_polar_activities(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
"""Pull exercises from Polar AccessLink."""
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "polar")
)
token = result.scalar_one_or_none()
if not token:
return []
headers = {"Authorization": f"Bearer {token.access_token}", "Accept": "application/json"}
# List available exercises (transactional endpoint)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{POLAR_API}/users/exercise-transactions", headers=headers)
if resp.status_code != 200:
return []
tx_data = resp.json()
imported = []
for tx in tx_data.get("exercise-transactions", []):
tx_url = tx.get("resource-uri", "")
if not tx_url:
continue
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(tx_url, headers=headers)
if resp.status_code != 200:
continue
exercises = resp.json().get("exercises", [])
for ex in exercises:
ext_id = str(ex.get("id", ""))
if not ext_id:
continue
# Skip duplicates
existing = await db.execute(
select(ActivityLog).where(
ActivityLog.user_id == user_id,
ActivityLog.source == "polar",
ActivityLog.external_id == ext_id,
)
)
if existing.scalar_one_or_none():
continue
start = ex.get("start-time", "")
try:
activity_date = datetime.fromisoformat(start).date()
except (ValueError, TypeError):
from datetime import date
activity_date = date.today()
# Parse duration ISO 8601 (e.g., PT1H30M)
duration_str = ex.get("duration", "")
duration_min = None
if duration_str:
import re
match = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?", duration_str)
if match:
hours = int(match.group(1) or 0)
mins = int(match.group(2) or 0)
duration_min = hours * 60 + mins
entry = await log_activity_with_points(
db=db,
user_id=user_id,
category="workout",
activity_date=activity_date,
title=ex.get("sport", "Polar Exercise"),
duration_minutes=duration_min,
source="polar",
external_id=ext_id,
metadata={
"calories": ex.get("calories"),
"heart_rate_avg": ex.get("heart-rate", {}).get("average"),
"sport": ex.get("sport"),
"distance": ex.get("distance"),
},
)
imported.append({
"id": str(entry.id),
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": activity_date.isoformat(),
})
return imported

View file

@ -1,267 +0,0 @@
"""Program research service — crawl fitness programs and extract structured data via Groq."""
from __future__ import annotations
import json
import re
import subprocess
import uuid
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
from app.config import settings
# ── Known program sources (skip search for these) ──────────────────────────
KNOWN_SOURCES: dict[str, list[str]] = {
"stronglifts 5x5": ["https://stronglifts.com/5x5/"],
"stronglifts": ["https://stronglifts.com/5x5/"],
"starting strength": ["https://startingstrength.com/get-started/programs"],
"couch to 5k": [
"https://www.nhs.uk/live-well/exercise/running-and-aerobic-exercises/get-running-with-couch-to-5k/"
],
"c25k": [
"https://www.nhs.uk/live-well/exercise/running-and-aerobic-exercises/get-running-with-couch-to-5k/"
],
"greyskull lp": ["https://physiqz.com/powerlifting-programs/greyskull-lp-best-powerlifting-routine-for-beginners/"],
"5/3/1": ["https://www.jimwendler.com/blogs/jimwendler-com/101065094-5-3-1-for-a-beginner"],
"wendler 531": ["https://www.jimwendler.com/blogs/jimwendler-com/101065094-5-3-1-for-a-beginner"],
"push pull legs": ["https://www.muscleandstrength.com/workouts/6-day-push-pull-legs-planet-fitness-workout"],
"ppl": ["https://www.muscleandstrength.com/workouts/6-day-push-pull-legs-planet-fitness-workout"],
"phul": ["https://www.muscleandstrength.com/workouts/phul-workout"],
"power hypertrophy upper lower": ["https://www.muscleandstrength.com/workouts/phul-workout"],
"darebee foundation": ["https://darebee.com/programs/foundation-light-program.html"],
"foundation light": ["https://darebee.com/programs/foundation-light-program.html"],
"darebee 30 days of change": ["https://darebee.com/programs/30-days-of-change.html"],
}
# ── Groq extraction prompt ─────────────────────────────────────────────────
EXTRACTION_PROMPT = """You are a fitness program parser. Extract the workout program from the content below into structured JSON.
Return ONLY valid JSON matching this exact schema no markdown fences, no explanation:
{
"name": "Program Name",
"description": "Brief 1-2 sentence description",
"duration_weeks": 12,
"frequency_per_week": 3,
"difficulty": "beginner",
"category": "strength",
"equipment": ["barbell", "squat rack", "bench"],
"progression_rules": "Human-readable progression description. E.g. 'Add 2.5kg per session on all lifts. If you fail a weight 3 sessions in a row, deload 10%.'",
"workouts": [
{
"week": 1,
"day": 1,
"name": "Workout A",
"rest_day": false,
"exercises": [
{
"name": "Squat",
"sets": 5,
"reps": 5,
"weight_instruction": "Start with empty bar (20kg), add 2.5kg each session",
"rest_seconds": 180,
"notes": "Below parallel"
}
],
"notes": null
}
]
}
Rules:
- difficulty must be one of: beginner, intermediate, advanced
- category must be one of: strength, cardio, flexibility, hybrid
- For alternating programs (A/B/A rotation), output the minimum unique weeks needed to show the pattern. Explain the rotation in progression_rules.
- If a day is a rest day, set rest_day: true and exercises: []
- weight_instruction should be specific if the source provides guidance, otherwise "Use appropriate weight"
- rest_seconds: use the source value, or 60-90 for hypertrophy, 180-300 for strength, 30-60 for cardio
- Include ALL exercises mentioned for each workout day
"""
def slugify(name: str) -> str:
"""Convert program name to URL-safe slug."""
s = name.lower().strip()
s = re.sub(r"[^a-z0-9\s-]", "", s)
s = re.sub(r"[\s-]+", "-", s)
return s[:200]
def find_urls_for_program(query: str) -> list[str]:
"""Look up known URLs or return empty list for unknown programs."""
q = query.lower().strip()
for key, urls in KNOWN_SOURCES.items():
if key in q or q in key:
return urls
return []
async def crawl_url(url: str) -> str:
"""Call ttp-crawler on subo to fetch a URL as markdown."""
proc = subprocess.run(
[
"python3", "/home/claude/ttp-crawler/crawl.py",
"fetch", url, "--output", "md",
],
capture_output=True,
text=True,
timeout=120,
cwd="/home/claude/ttp-crawler",
)
if proc.returncode != 0:
raise RuntimeError(f"Crawl failed: {proc.stderr[:500]}")
return proc.stdout
async def extract_with_groq(crawled_content: str) -> dict[str, Any]:
"""Send crawled markdown to Groq for structured extraction."""
# Truncate to fit context — Llama 3.3 70B has 128k context but we keep it reasonable
content = crawled_content[:15000]
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.groq_api_key}",
"Content-Type": "application/json",
},
json={
"model": "llama-3.3-70b-versatile",
"messages": [
{"role": "system", "content": EXTRACTION_PROMPT},
{"role": "user", "content": content},
],
"response_format": {"type": "json_object"},
"temperature": 0.1,
"max_tokens": 8000,
},
)
resp.raise_for_status()
data = resp.json()
raw = data["choices"][0]["message"]["content"]
return json.loads(raw)
def validate_extraction(data: dict) -> tuple[bool, str]:
"""Basic validation of Groq extraction output."""
required = ["name", "workouts"]
for field in required:
if field not in data:
return False, f"Missing required field: {field}"
if not isinstance(data.get("workouts"), list) or len(data["workouts"]) == 0:
return False, "No workouts extracted"
for i, w in enumerate(data["workouts"]):
if not w.get("rest_day", False) and not w.get("exercises"):
return False, f"Workout {i + 1} has no exercises and is not a rest day"
return True, "OK"
async def populate_catalog_from_extraction(
db: AsyncSession, catalog: ProgramCatalog, data: dict
) -> None:
"""Write validated extraction data into catalog + catalog_workouts."""
catalog.name = data.get("name", catalog.name)
catalog.description = data.get("description")
catalog.duration_weeks = data.get("duration_weeks")
catalog.frequency_per_week = data.get("frequency_per_week")
catalog.equipment = data.get("equipment", [])
catalog.difficulty = data.get("difficulty")
catalog.category = data.get("category")
catalog.progression_rules = data.get("progression_rules")
catalog.structured_data = data
catalog.crawl_status = "ready"
catalog.crawled_at = datetime.now(timezone.utc)
for w in data.get("workouts", []):
workout = CatalogWorkout(
catalog_id=catalog.id,
week_number=w.get("week", 1),
day_number=w.get("day", 1),
workout_name=w.get("name"),
exercises=w.get("exercises", []),
notes=w.get("notes"),
rest_day=w.get("rest_day", False),
)
db.add(workout)
await db.flush()
async def process_crawl_job(db: AsyncSession, job: CrawlQueue) -> None:
"""Execute a single crawl queue job end-to-end."""
job.status = "running"
job.started_at = datetime.now(timezone.utc)
await db.flush()
catalog = None
if job.catalog_id:
result = await db.execute(
select(ProgramCatalog).where(ProgramCatalog.id == job.catalog_id)
)
catalog = result.scalar_one_or_none()
try:
# Step 1: Determine URLs to crawl
urls = job.urls_to_crawl if job.urls_to_crawl else find_urls_for_program(job.search_query)
if not urls:
raise ValueError(
f"No known sources for '{job.search_query}'. "
"Add URLs manually or extend KNOWN_SOURCES."
)
# Step 2: Crawl
crawled_parts = []
for url in urls:
try:
content = await crawl_url(url)
if len(content.strip()) > 200: # skip near-empty crawls
crawled_parts.append(content)
except Exception as e:
crawled_parts.append(f"[Crawl error for {url}: {e}]")
if not crawled_parts:
raise ValueError("All crawl attempts returned empty content")
combined = "\n\n---\n\n".join(crawled_parts)
job.crawled_content = combined[:50000] # store for debugging, cap at 50KB
# Step 3: Extract via Groq
if catalog:
catalog.crawl_status = "extracting"
await db.flush()
extracted = await extract_with_groq(combined)
# Step 4: Validate
valid, reason = validate_extraction(extracted)
if not valid:
raise ValueError(f"Extraction validation failed: {reason}")
# Step 5: Populate catalog
if catalog:
await populate_catalog_from_extraction(db, catalog, extracted)
job.status = "done"
job.completed_at = datetime.now(timezone.utc)
except Exception as e:
job.status = "failed"
job.error = str(e)[:2000]
job.completed_at = datetime.now(timezone.utc)
if catalog:
catalog.crawl_status = "failed"
catalog.crawl_error = str(e)[:2000]
await db.flush()

View file

@ -1,235 +0,0 @@
"""Provider registry — drives integration UI cards and agent help text."""
from __future__ import annotations
PROVIDER_REGISTRY: dict[str, dict] = {
# ═══ Device Integrations ═══
"strava": {
"name": "Strava",
"type": "oauth2",
"category": "device",
"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",
"warning": "Strava ToS prohibits use of their data for AI/ML. Activity sync works but AI coaching should not reference Strava data.",
"has_webhook": False,
"sync_service": "strava_sync",
},
"polar": {
"name": "Polar",
"type": "oauth2",
"category": "device",
"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",
"has_webhook": False,
"sync_service": "polar_sync",
},
"garmin": {
"name": "Garmin Connect",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://connect.garmin.com/oauthConfirm",
"token_url": "https://connectapi.garmin.com/oauth-service/oauth/token",
"scopes": "HEALTH,ACTIVITY",
"help_url": "https://developer.garmin.com/gc-developer-program/",
"help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.",
"has_webhook": True,
"sync_service": "garmin_sync",
},
"whoop": {
"name": "WHOOP",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth",
"token_url": "https://api.prod.whoop.com/oauth/oauth2/token",
"scopes": "read:workout,read:recovery,read:sleep,read:profile,offline",
"help_url": "https://developer.whoop.com/",
"help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.",
"has_webhook": True,
"sync_service": "whoop_sync",
},
"oura": {
"name": "Oura Ring",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://cloud.ouraring.com/oauth/authorize",
"token_url": "https://api.ouraring.com/oauth/token",
"scopes": "daily,workout,heartrate,session",
"help_url": "https://cloud.ouraring.com/v2/docs",
"help_text": "Register at cloud.ouraring.com — self-serve, immediate access.",
"has_webhook": True,
"sync_service": "oura_sync",
},
"coros": {
"name": "COROS (via MCP)",
"type": "mcp_bridge",
"category": "device",
"fields": [],
"help_url": "https://support.coros.com/hc/en-us/articles/17085887816340",
"help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.",
"has_webhook": False,
"sync_service": None,
},
"fitbit": {
"name": "Fitbit",
"type": "oauth2",
"category": "device",
"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 via Google Cloud Console.",
"has_webhook": True,
"sync_service": None,
},
"withings": {
"name": "Withings",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"help_url": "https://developer.withings.com/",
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.",
"has_webhook": False,
"sync_service": None,
},
"suunto": {
"name": "Suunto",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"help_url": "https://apizone.suunto.com/",
"help_text": "Apply at Suunto developer portal — contract required, ~2 week response.",
"has_webhook": True,
"sync_service": None,
},
# ═══ AI Coaching Providers ═══
"openai": {
"name": "OpenAI",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://api.openai.com/v1",
"api_format": "openai",
"help_url": "https://platform.openai.com/api-keys",
"help_text": "Get API key from platform.openai.com. Models: gpt-4o, gpt-4o-mini.",
"default_model": "gpt-4o-mini",
},
"openrouter": {
"name": "OpenRouter",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://openrouter.ai/api/v1",
"api_format": "openai",
"help_url": "https://openrouter.ai/keys",
"help_text": "One key, 300+ models. 26 free models. Pay-as-you-go, no subscription.",
"default_model": "openai/gpt-4o-mini",
},
"huggingface": {
"name": "Hugging Face",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://router.huggingface.co/v1",
"api_format": "openai",
"help_url": "https://huggingface.co/settings/tokens",
"help_text": "Free HF token. Thousands of open-source models via 20+ inference providers.",
"default_model": "meta-llama/Llama-3.3-70B-Instruct",
},
"groq": {
"name": "Groq",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://api.groq.com/openai/v1",
"api_format": "openai",
"help_url": "https://console.groq.com/keys",
"help_text": "Ultra-fast inference. Free tier available. Also used for program research.",
"default_model": "llama-3.3-70b-versatile",
},
"ollama": {
"name": "Ollama (Local)",
"type": "endpoint",
"category": "ai_provider",
"fields": ["endpoint_url"],
"base_url": "http://host.docker.internal:11434/v1",
"api_format": "openai",
"help_url": "https://ollama.com/",
"help_text": "Run LLMs locally. No API key, no cost. Default: http://host.docker.internal:11434",
"default_model": "llama3.1",
},
"claude": {
"name": "Anthropic Claude",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://api.anthropic.com/v1",
"api_format": "anthropic",
"help_url": "https://console.anthropic.com/",
"help_text": "Get API key from console.anthropic.com. Models: claude-sonnet-4-6, claude-opus-4-8.",
"default_model": "claude-sonnet-4-6",
},
"gemini": {
"name": "Google Gemini",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://generativelanguage.googleapis.com/v1beta",
"api_format": "gemini",
"help_url": "https://aistudio.google.com/apikey",
"help_text": "Get API key from AI Studio. Models: gemini-2.0-flash, gemini-2.5-pro.",
"default_model": "gemini-2.0-flash",
},
"custom_ai": {
"name": "Custom (OpenAI-compatible)",
"type": "endpoint",
"category": "ai_provider",
"fields": ["endpoint_url", "api_key"],
"api_format": "openai",
"help_url": "",
"help_text": "Any server with an OpenAI-compatible /v1/chat/completions endpoint (vLLM, TGI, LiteLLM).",
"default_model": "",
},
# ═══ Nutrition & Data ═══
"usda": {
"name": "USDA FoodData Central",
"type": "api_key",
"category": "nutrition",
"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",
"category": "nutrition",
"fields": ["app_id", "api_key"],
"help_url": "https://developer.nutritionix.com/",
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
},
# ═══ Notifications ═══
"telegram": {
"name": "Telegram Notifications",
"type": "api_key",
"category": "notifications",
"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.",
},
}

View file

@ -1,88 +0,0 @@
from __future__ import annotations
"""Resource recommendation engine — matches external programs to user profiles."""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.resource import Resource
from app.models.profile import UserProfile
# Equipment categories that satisfy each resource requirement
EQUIPMENT_SATISFIES = {
"none": set(), # bodyweight — always available
"basic_home": {"dumbbells", "resistance_bands", "pull_up_bar", "kettlebell"},
"full_gym": {"barbell", "squat_rack", "bench_press", "machines", "cable_machine", "dumbbells"},
}
async def get_recommendations(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
profile_result = await db.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = profile_result.scalar_one_or_none()
if not profile:
return []
query = select(Resource).where(Resource.is_active == True)
# Filter by equipment: if user has specific equipment, determine what resource levels they can do
user_equipment = set(profile.equipment_list or [])
if not user_equipment:
# No equipment selected — only show bodyweight programs
query = query.where(Resource.equipment_needed == "none")
elif user_equipment & EQUIPMENT_SATISFIES["full_gym"]:
# Has gym-level equipment — show everything
pass
elif user_equipment & EQUIPMENT_SATISFIES["basic_home"]:
# Has some home equipment — show none + basic_home
query = query.where(Resource.equipment_needed.in_(["none", "basic_home"]))
else:
# Only has non-gym items (bicycle, pool, etc.) — show bodyweight
query = query.where(Resource.equipment_needed == "none")
result = await db.execute(query)
resources = result.scalars().all()
user_prefs = set(profile.activity_preferences or [])
user_goal = profile.primary_goal
user_stage = profile.ttm_stage
difficulty_map = {
"precontemplation": "beginner",
"contemplation": "beginner",
"preparation": "beginner",
"action": "intermediate",
"maintenance": "advanced",
}
scored = []
for r in resources:
score = 0
if user_goal and user_goal in (r.goal_tags or []):
score += 30
overlap = user_prefs & set(r.activity_tags or [])
score += len(overlap) * 15
if user_stage and user_stage in (r.ttm_stages or []):
score += 20
if r.difficulty == difficulty_map.get(user_stage):
score += 10
scored.append((r, score))
scored.sort(key=lambda x: x[1], reverse=True)
return [
{
"id": str(r.id),
"name": r.name,
"source": r.source,
"url": r.url,
"description": r.description,
"difficulty": r.difficulty,
"duration_days": r.duration_days,
"goal_tags": r.goal_tags,
"activity_tags": r.activity_tags,
"score": s,
}
for r, s in scored[:8]
]

View file

@ -1,164 +0,0 @@
from __future__ import annotations
"""Strava API client for activity sync."""
import uuid
from datetime import datetime, timezone
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.oauth import OAuthToken
from app.models.activity import ActivityLog
from app.services.points_engine import log_activity_with_points
settings = get_settings()
STRAVA_AUTH_URL = "https://www.strava.com/oauth/authorize"
STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
STRAVA_API = "https://www.strava.com/api/v3"
# Map Strava activity types to our categories
STRAVA_TYPE_MAP = {
"Run": "workout",
"Ride": "workout",
"Swim": "workout",
"Walk": "workout",
"Hike": "workout",
"WeightTraining": "workout",
"Crossfit": "workout",
"Yoga": "workout",
"Workout": "workout",
}
def get_strava_auth_url(user_id: str) -> str:
"""Generate the Strava OAuth authorization URL."""
return (
f"{STRAVA_AUTH_URL}?client_id={settings.strava_client_id}"
f"&response_type=code"
f"&redirect_uri={settings.base_url}/api/integrations/strava/callback"
f"&scope=read,activity:read_all"
f"&approval_prompt=force"
f"&state={user_id}"
)
async def exchange_strava_code(code: str) -> dict:
"""Exchange authorization code for tokens."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(STRAVA_TOKEN_URL, data={
"client_id": settings.strava_client_id,
"client_secret": settings.strava_client_secret,
"code": code,
"grant_type": "authorization_code",
})
resp.raise_for_status()
return resp.json()
async def refresh_strava_token(token: OAuthToken, db: AsyncSession) -> OAuthToken:
"""Refresh an expired Strava access token."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(STRAVA_TOKEN_URL, data={
"client_id": settings.strava_client_id,
"client_secret": settings.strava_client_secret,
"refresh_token": token.refresh_token,
"grant_type": "refresh_token",
})
resp.raise_for_status()
data = resp.json()
token.access_token = data["access_token"]
token.refresh_token = data.get("refresh_token", token.refresh_token)
token.expires_at = datetime.fromtimestamp(data["expires_at"], tz=timezone.utc)
token.updated_at = datetime.now(timezone.utc)
await db.flush()
return token
async def get_valid_token(db: AsyncSession, user_id: uuid.UUID) -> OAuthToken | None:
"""Get a valid Strava token, refreshing if needed."""
result = await db.execute(
select(OAuthToken).where(OAuthToken.user_id == user_id, OAuthToken.provider == "strava")
)
token = result.scalar_one_or_none()
if not token:
return None
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
token = await refresh_strava_token(token, db)
return token
async def sync_strava_activities(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
"""Pull new activities from Strava and award points."""
token = await get_valid_token(db, user_id)
if not token:
return []
# Find the most recent synced activity date to avoid duplicates
result = await db.execute(
select(ActivityLog.logged_at)
.where(ActivityLog.user_id == user_id, ActivityLog.source == "strava")
.order_by(ActivityLog.logged_at.desc())
.limit(1)
)
last_sync = result.scalar_one_or_none()
after_ts = int(last_sync.timestamp()) if last_sync else 0
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"{STRAVA_API}/athlete/activities",
params={"after": after_ts, "per_page": 30},
headers={"Authorization": f"Bearer {token.access_token}"},
)
if resp.status_code != 200:
return []
activities = resp.json()
imported = []
for act in activities:
ext_id = str(act["id"])
# Skip if already imported
existing = await db.execute(
select(ActivityLog).where(
ActivityLog.user_id == user_id,
ActivityLog.source == "strava",
ActivityLog.external_id == ext_id,
)
)
if existing.scalar_one_or_none():
continue
category = STRAVA_TYPE_MAP.get(act.get("type", ""), "workout")
start_local = act.get("start_date_local", act.get("start_date", ""))
activity_date = datetime.fromisoformat(start_local.replace("Z", "+00:00")).date()
entry = await log_activity_with_points(
db=db,
user_id=user_id,
category=category,
activity_date=activity_date,
title=act.get("name", "Strava Activity"),
duration_minutes=(act.get("elapsed_time", 0) // 60) or None,
source="strava",
external_id=ext_id,
metadata={
"distance_km": round(act.get("distance", 0) / 1000, 2),
"calories": act.get("calories"),
"average_heartrate": act.get("average_heartrate"),
"type": act.get("type"),
"moving_time": act.get("moving_time"),
},
)
imported.append({
"id": str(entry.id),
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": activity_date.isoformat(),
})
return imported

View file

@ -1,59 +0,0 @@
"""USDA FoodData Central lookup service.
Free API with 400K+ foods and research-grade nutrition data.
API key required (free, instant signup at https://fdc.nal.usda.gov/api-key-signup).
"""
from __future__ import annotations
import logging
import httpx
logger = logging.getLogger(__name__)
USDA_BASE = "https://api.nal.usda.gov/fdc/v1"
async def search_usda(query: str, api_key: str, page_size: int = 10) -> list[dict]:
"""Search USDA FoodData Central for food items.
Returns a list of dicts with: fdcId, description, brandName, calories,
protein_g, carbs_g, fat_g, serving_size.
"""
if not api_key:
return []
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{USDA_BASE}/foods/search",
params={
"api_key": api_key,
"query": query,
"pageSize": page_size,
"dataType": "Foundation,SR Legacy,Branded",
},
)
resp.raise_for_status()
data = resp.json()
results = []
for food in data.get("foods", []):
nutrients = {n["nutrientName"]: n["value"] for n in food.get("foodNutrients", [])}
results.append({
"fdcId": food.get("fdcId"),
"description": food.get("description", ""),
"brandName": food.get("brandName"),
"source": "usda",
"calories": nutrients.get("Energy"),
"protein_g": nutrients.get("Protein"),
"carbs_g": nutrients.get("Carbohydrate, by difference"),
"fat_g": nutrients.get("Total lipid (fat)"),
"fiber_g": nutrients.get("Fiber, total dietary"),
"serving_size": food.get("servingSize"),
"serving_unit": food.get("servingSizeUnit"),
})
return results
except Exception as e:
logger.warning(f"USDA search failed: {e}")
return []

View file

@ -1,79 +0,0 @@
from __future__ import annotations
import uuid
import bcrypt
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
settings = get_settings()
ALGORITHM = "HS256" # Hardcoded — not configurable for security
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(user_id: str, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
payload = {"sub": user_id, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
async def get_current_user(
token: Annotated[str | None, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
from app.models.user import User
# Check if this is an API token (MCP connector auth)
if settings.api_token and token == settings.api_token:
# Map to the first admin user
result = await db.execute(
select(User).where(User.is_admin == True).order_by(User.created_at.asc()).limit(1)
)
user = result.scalar_one_or_none()
if user is None:
# Fall back to first user if no admin exists yet
result = await db.execute(
select(User).order_by(User.created_at.asc()).limit(1)
)
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
# Standard JWT validation
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user

View file

@ -1,20 +0,0 @@
from __future__ import annotations
from datetime import date, timedelta
def get_week_boundaries(d: date) -> tuple[date, date]:
"""Return Monday and Sunday of the week containing date d."""
monday = d - timedelta(days=d.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
def get_month_boundaries(year: int, month: int) -> tuple[date, date]:
"""Return first and last day of a month."""
first = date(year, month, 1)
if month == 12:
last = date(year + 1, 1, 1) - timedelta(days=1)
else:
last = date(year, month + 1, 1) - timedelta(days=1)
return first, last

View file

@ -1,12 +1,14 @@
# Backend
# Core
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
httpx==0.28.1
python-multipart==0.0.18
# Database drivers
asyncpg==0.30.0
aiosqlite==0.20.0