Initial commit: full fitness-rewards app
Backend: FastAPI + PostgreSQL + SQLAlchemy async - Auth (JWT), onboarding (PAR-Q+, TTM, BREQ-2), activities, food log - Points engine with daily gate + weekly targets - Strava + Polar OAuth integration - Open Food Facts barcode lookup - Resource recommendation engine - 10 seeded Darebee/StrongLifts/YouTube programs Frontend: React + Vite, mobile-first dark theme - Login/Register, 8-step onboarding flow - Dashboard with daily gate status - Activity logger, food logger (manual + barcode scan + search) - Reward shop with redemption - Weekly summary view - Settings (point rules, targets, integrations) Deployment: Docker Compose for Coolify (Traefik)
This commit is contained in:
commit
4db2b0846b
61 changed files with 4280 additions and 0 deletions
0
backend/app/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue