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:
claude 2026-03-14 23:53:00 +00:00
commit 4db2b0846b
61 changed files with 4280 additions and 0 deletions

0
backend/app/__init__.py Normal file
View file

31
backend/app/config.py Normal file
View file

@ -0,0 +1,31 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "postgresql+asyncpg://fitness:fitness@db:5432/fitness_rewards"
# Auth
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 1440 # 24 hours
# Strava
strava_client_id: str = ""
strava_client_secret: str = ""
# Polar
polar_client_id: str = ""
polar_client_secret: str = ""
# App
base_url: str = "https://fitness.littlefake.com"
timezone: str = "Asia/Bangkok"
model_config = {"env_file": ".env", "extra": "ignore"}
@lru_cache
def get_settings() -> Settings:
return Settings()

29
backend/app/database.py Normal file
View file

@ -0,0 +1,29 @@
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():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

183
backend/app/main.py Normal file
View file

@ -0,0 +1,183 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
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
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
await seed_resources()
yield
app = FastAPI(title="Fitness Rewards", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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(programs_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "0.1.0"}
async def seed_resources():
"""Seed the resource library with curated fitness programs."""
from app.database import async_session
from app.models.resource import Resource
from sqlalchemy import select
async with async_session() as db:
existing = await db.execute(select(Resource).limit(1))
if existing.scalar_one_or_none():
return # Already seeded
resources = [
Resource(
name="Darebee Foundation Program",
source="darebee",
url="https://darebee.com/programs/foundation-program.html",
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
goal_tags=["get_active", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee 30 Days of Change",
source="darebee",
url="https://darebee.com/programs/30-days-of-change.html",
description="30-day progressive program for building fitness habits. Bodyweight only.",
goal_tags=["get_active", "lose_weight", "feel_better"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee IRONHEART",
source="darebee",
url="https://darebee.com/programs/ironheart.html",
description="Strength-focused bodyweight program. No equipment, all levels.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee Total Body Strength",
source="darebee",
url="https://darebee.com/programs/total-body-strength.html",
description="Comprehensive strength building program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="intermediate",
duration_days=30,
),
Resource(
name="Darebee 8 Weeks to 5K",
source="darebee",
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
description="Progressive running program from beginner to 5K in 8 weeks.",
goal_tags=["get_active", "lose_weight"],
activity_tags=["running"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=56,
),
Resource(
name="StrongLifts 5x5",
source="stronglifts",
url="https://stronglifts.com/5x5/",
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
goal_tags=["build_strength"],
activity_tags=["weights"],
equipment_needed="full_gym",
ttm_stages=["preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=90,
),
Resource(
name="Darebee 30 Days of Cardio",
source="darebee",
url="https://darebee.com/programs/30-days-of-cardio.html",
description="30-day cardio program, no equipment. Great for weight loss.",
goal_tags=["lose_weight", "get_active"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["preparation", "action"],
difficulty="beginner",
duration_days=30,
),
Resource(
name="Darebee POWERBUILDER",
source="darebee",
url="https://darebee.com/programs/powerbuilder-program.html",
description="Advanced strength and power program using bodyweight.",
goal_tags=["build_strength"],
activity_tags=["bodyweight"],
equipment_needed="none",
ttm_stages=["action", "maintenance"],
difficulty="advanced",
duration_days=30,
),
Resource(
name="Fitness Blender (YouTube)",
source="youtube",
url="https://www.youtube.com/@fitnessblender",
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
activity_tags=["bodyweight", "yoga"],
equipment_needed="none",
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
difficulty="beginner",
duration_days=None,
),
Resource(
name="Darebee Yoga Flexibility Program",
source="darebee",
url="https://darebee.com/programs/flexibility-program.html",
description="30-day flexibility and yoga program for beginners.",
goal_tags=["feel_better"],
activity_tags=["yoga"],
equipment_needed="none",
ttm_stages=["precontemplation", "contemplation", "preparation"],
difficulty="beginner",
duration_days=30,
),
]
for r in resources:
db.add(r)
await db.commit()

View file

@ -0,0 +1,15 @@
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
__all__ = [
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
"OAuthToken", "Resource",
]

View file

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

View file

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

View file

@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class OAuthToken(Base):
__tablename__ = "oauth_tokens"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
provider: Mapped[str] = mapped_column(String(20), nullable=False)
access_token: Mapped[str] = mapped_column(Text, nullable=False)
refresh_token: Mapped[str | None] = mapped_column(Text)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
athlete_id: Mapped[str | None] = mapped_column(String(100))
scope: Mapped[str | None] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint("user_id", "provider", name="uq_user_provider"),
)

View file

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

View file

@ -0,0 +1,53 @@
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))
days_per_week: Mapped[int] = mapped_column(Integer, default=3)
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)
# Completion
phase1_completed: Mapped[bool] = mapped_column(Boolean, default=False)
phase2_completed: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
user: Mapped["User"] = relationship(back_populates="profile")

View file

@ -0,0 +1,23 @@
import uuid
from datetime import datetime, date, timezone
from sqlalchemy import String, 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))
user: Mapped["User"] = relationship(back_populates="programs")

View file

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

View file

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

View file

@ -0,0 +1,25 @@
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")
# Relationships
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin")
programs: Mapped[list["Program"]] = relationship(back_populates="user", lazy="selectin")
point_rules: Mapped[list["PointRule"]] = relationship(back_populates="user", lazy="selectin")
rewards: Mapped[list["Reward"]] = relationship(back_populates="user", lazy="selectin")

View file

View 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"}

View 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
View 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}

View 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}

View 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}

View 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

View 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}

View file

View file

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

View file

@ -0,0 +1,28 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
display_name: str
email: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: str
username: str
display_name: str
email: str | None
timezone: str
model_config = {"from_attributes": True}

View file

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

View file

@ -0,0 +1,42 @@
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 # none, basic_home, full_gym
days_per_week: int = 3
minutes_per_session: int = 30
class OnboardingStatusResponse(BaseModel):
phase1_completed: bool
phase2_completed: bool
primary_goal: str | None = None
ttm_stage: str | None = None
model_config = {"from_attributes": True}

View file

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

View file

@ -0,0 +1,29 @@
from datetime import date
from pydantic import BaseModel
class RewardCreate(BaseModel):
name: str
description: str | None = None
point_cost: int
class RewardResponse(BaseModel):
id: str
name: str
description: str | None
point_cost: int
is_active: bool
model_config = {"from_attributes": True}
class RewardRedeemRequest(BaseModel):
date: date | None = None # defaults to today
class RedemptionResponse(BaseModel):
id: str
reward_name: str
points_spent: int
redemption_date: date

View file

View file

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

View file

@ -0,0 +1,268 @@
"""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_day": program["day"] if program else None,
"program_total_days": program["total_days"] if program else None,
"program_name": program["name"] if program else None,
}
async def log_activity_with_points(
db: AsyncSession,
user_id: uuid.UUID,
category: str,
activity_date: date,
title: str | None = None,
description: str | None = None,
duration_minutes: int | None = None,
source: str = "manual",
external_id: str | None = None,
program_id: uuid.UUID | None = None,
program_day: int | None = None,
metadata: dict | None = None,
) -> ActivityLog:
"""Log an activity and auto-calculate points from active rules."""
# Find matching point rule
result = await db.execute(
select(PointRule).where(
PointRule.user_id == user_id,
PointRule.category == category,
PointRule.is_active == True,
)
)
rule = result.scalar_one_or_none()
points = 0
rule_id = None
if rule:
rule_id = rule.id
if rule.unit == "per_hour" and duration_minutes:
points = rule.points * (duration_minutes / 60)
else:
points = rule.points
activity = ActivityLog(
user_id=user_id,
category=category,
title=title,
description=description,
duration_minutes=duration_minutes,
source=source,
external_id=external_id,
points_earned=int(points),
rule_id=rule_id,
activity_date=activity_date,
program_id=program_id,
program_day=program_day,
metadata_json=metadata or {},
)
db.add(activity)
await db.flush()
return activity
async def redeem_reward(
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
) -> dict:
"""Attempt to redeem a reward. Returns success/failure with details."""
d = redemption_date or date.today()
earned = await get_daily_points_earned(db, user_id, d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
if earned < daily_min:
return {"success": False, "reason": f"Need {daily_min} points today, have {earned}"}
result = await db.execute(select(Reward).where(Reward.id == reward_id, Reward.user_id == user_id))
reward = result.scalar_one_or_none()
if not reward:
return {"success": False, "reason": "Reward not found"}
redemption = RewardRedemption(
user_id=user_id,
reward_id=reward_id,
points_spent=reward.point_cost,
redemption_date=d,
)
db.add(redemption)
await db.flush()
return {
"success": True,
"id": str(redemption.id),
"reward_name": reward.name,
"points_spent": reward.point_cost,
"redemption_date": d.isoformat(),
}
async def get_weekly_summary(db: AsyncSession, user_id: uuid.UUID, d: date) -> dict:
"""Generate weekly summary for the week containing date d."""
mon, sun = get_week_boundaries(d)
target = await get_daily_target(db, user_id)
daily_min = target.daily_minimum_pts if target else 80
weekly_tgt = target.weekly_target_pts if target else 500
bonus = target.weekly_bonus_pts if target else 50
daily_breakdown = []
total_earned = 0
total_spent = 0
active_days = 0
gate_days = 0
for i in range(7):
day = mon + timedelta(days=i)
earned = await get_daily_points_earned(db, user_id, day)
spent = await get_daily_points_spent(db, user_id, day)
gate = earned >= daily_min
total_earned += earned
total_spent += spent
if earned > 0:
active_days += 1
if gate:
gate_days += 1
daily_breakdown.append({
"date": day.isoformat(),
"points_earned": earned,
"points_spent": spent,
"gate_passed": gate,
"daily_minimum": daily_min,
})
hit_target = total_earned >= weekly_tgt
return {
"week_start": mon.isoformat(),
"week_end": sun.isoformat(),
"total_points_earned": total_earned,
"total_points_spent": total_spent,
"active_days": active_days,
"gate_passed_days": gate_days,
"hit_weekly_target": hit_target,
"weekly_target": weekly_tgt,
"weekly_bonus_earned": bonus if hit_target else 0,
"daily_breakdown": daily_breakdown,
}

View file

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

View file

@ -0,0 +1,67 @@
"""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
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)
if profile.equipment_access == "none":
query = query.where(Resource.equipment_needed == "none")
elif profile.equipment_access == "basic_home":
query = query.where(Resource.equipment_needed.in_(["none", "basic_home"]))
result = await db.execute(query)
resources = result.scalars().all()
user_prefs = set(profile.activity_preferences or [])
user_goal = profile.primary_goal
user_stage = profile.ttm_stage
difficulty_map = {
"precontemplation": "beginner",
"contemplation": "beginner",
"preparation": "beginner",
"action": "intermediate",
"maintenance": "advanced",
}
scored = []
for r in resources:
score = 0
if user_goal and user_goal in (r.goal_tags or []):
score += 30
overlap = user_prefs & set(r.activity_tags or [])
score += len(overlap) * 15
if user_stage and user_stage in (r.ttm_stages or []):
score += 20
if r.difficulty == difficulty_map.get(user_stage):
score += 10
scored.append((r, score))
scored.sort(key=lambda x: x[1], reverse=True)
return [
{
"id": str(r.id),
"name": r.name,
"source": r.source,
"url": r.url,
"description": r.description,
"difficulty": r.difficulty,
"duration_days": r.duration_days,
"goal_tags": r.goal_tags,
"activity_tags": r.activity_tags,
"score": s,
}
for r, s in scored[:8]
]

View file

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

View file

54
backend/app/utils/auth.py Normal file
View file

@ -0,0 +1,54 @@
import uuid
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 passlib.context import CryptContext
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()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
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=settings.algorithm)
async def get_current_user(
token: Annotated[str, 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"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
from app.models.user import User
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user

View file

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