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
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
31
backend/app/config.py
Normal file
31
backend/app/config.py
Normal 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
29
backend/app/database.py
Normal 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
183
backend/app/main.py
Normal 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()
|
||||
15
backend/app/models/__init__.py
Normal file
15
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
31
backend/app/models/activity.py
Normal file
31
backend/app/models/activity.py
Normal 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"),
|
||||
)
|
||||
35
backend/app/models/food.py
Normal file
35
backend/app/models/food.py
Normal 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"),
|
||||
)
|
||||
25
backend/app/models/oauth.py
Normal file
25
backend/app/models/oauth.py
Normal 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"),
|
||||
)
|
||||
52
backend/app/models/points.py
Normal file
52
backend/app/models/points.py
Normal 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},
|
||||
}
|
||||
53
backend/app/models/profile.py
Normal file
53
backend/app/models/profile.py
Normal 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")
|
||||
23
backend/app/models/program.py
Normal file
23
backend/app/models/program.py
Normal 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")
|
||||
25
backend/app/models/resource.py
Normal file
25
backend/app/models/resource.py
Normal 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))
|
||||
36
backend/app/models/reward.py
Normal file
36
backend/app/models/reward.py
Normal 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"),
|
||||
)
|
||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal 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")
|
||||
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}
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
30
backend/app/schemas/activity.py
Normal file
30
backend/app/schemas/activity.py
Normal 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}
|
||||
28
backend/app/schemas/auth.py
Normal file
28
backend/app/schemas/auth.py
Normal 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}
|
||||
53
backend/app/schemas/food.py
Normal file
53
backend/app/schemas/food.py
Normal 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
|
||||
42
backend/app/schemas/onboarding.py
Normal file
42
backend/app/schemas/onboarding.py
Normal 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}
|
||||
69
backend/app/schemas/points.py
Normal file
69
backend/app/schemas/points.py
Normal 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
|
||||
29
backend/app/schemas/reward.py
Normal file
29
backend/app/schemas/reward.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
76
backend/app/services/food_lookup.py
Normal file
76
backend/app/services/food_lookup.py
Normal 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
|
||||
268
backend/app/services/points_engine.py
Normal file
268
backend/app/services/points_engine.py
Normal 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,
|
||||
}
|
||||
145
backend/app/services/polar_sync.py
Normal file
145
backend/app/services/polar_sync.py
Normal 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
|
||||
67
backend/app/services/resource_matcher.py
Normal file
67
backend/app/services/resource_matcher.py
Normal 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]
|
||||
]
|
||||
162
backend/app/services/strava_sync.py
Normal file
162
backend/app/services/strava_sync.py
Normal 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
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
54
backend/app/utils/auth.py
Normal file
54
backend/app/utils/auth.py
Normal 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
|
||||
18
backend/app/utils/dates.py
Normal file
18
backend/app/utils/dates.py
Normal 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
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Backend
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.18
|
||||
Loading…
Add table
Add a link
Reference in a new issue