Initial commit: full fitness-rewards app
Backend: FastAPI + PostgreSQL + SQLAlchemy async - Auth (JWT), onboarding (PAR-Q+, TTM, BREQ-2), activities, food log - Points engine with daily gate + weekly targets - Strava + Polar OAuth integration - Open Food Facts barcode lookup - Resource recommendation engine - 10 seeded Darebee/StrongLifts/YouTube programs Frontend: React + Vite, mobile-first dark theme - Login/Register, 8-step onboarding flow - Dashboard with daily gate status - Activity logger, food logger (manual + barcode scan + search) - Reward shop with redemption - Weekly summary view - Settings (point rules, targets, integrations) Deployment: Docker Compose for Coolify (Traefik)
This commit is contained in:
commit
4db2b0846b
61 changed files with 4280 additions and 0 deletions
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
93
backend/app/routers/activities.py
Normal file
93
backend/app/routers/activities.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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"}
|
||||
65
backend/app/routers/auth.py
Normal file
65
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 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
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
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")
|
||||
|
||||
user = User(
|
||||
username=req.username,
|
||||
display_name=req.display_name,
|
||||
password_hash=hash_password(req.password),
|
||||
email=req.email,
|
||||
)
|
||||
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 TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
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 TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email=user.email,
|
||||
timezone=user.timezone,
|
||||
)
|
||||
111
backend/app/routers/food.py
Normal file
111
backend/app/routers/food.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
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}
|
||||
153
backend/app/routers/integrations.py
Normal file
153
backend/app/routers/integrations.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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)]):
|
||||
return {"auth_url": get_strava_auth_url(str(user.id))}
|
||||
|
||||
|
||||
@router.get("/strava/callback")
|
||||
async def strava_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = uuid_mod.UUID(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)]):
|
||||
return {"auth_url": get_polar_auth_url(str(user.id))}
|
||||
|
||||
|
||||
@router.get("/polar/callback")
|
||||
async def polar_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = uuid_mod.UUID(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}
|
||||
139
backend/app/routers/onboarding.py
Normal file
139
backend/app/routers/onboarding.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
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.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}
|
||||
147
backend/app/routers/points.py
Normal file
147
backend/app/routers/points.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
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.date)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["reason"])
|
||||
return result
|
||||
116
backend/app/routers/programs.py
Normal file
116
backend/app/routers/programs.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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,
|
||||
})
|
||||
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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue