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:
parent
0ce1463de8
commit
a6e6e2f54f
77 changed files with 1537 additions and 1007 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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))
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.",
|
||||
},
|
||||
}
|
||||
|
|
@ -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]
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue