diff --git a/backend/app/main.py b/backend/app/main.py index 4e88690..a3dff39 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -68,6 +68,22 @@ async def init_db(): except Exception as e: print(f"PR constraint migration skipped: {e}") + # Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted + # without first manually unlinking every activity. + try: + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE activities " + "DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey" + )) + await conn.execute(text( + "ALTER TABLE activities " + "ADD CONSTRAINT activities_named_route_id_fkey " + "FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL" + )) + except Exception as e: + print(f"FK migration skipped: {e}") + # Seed admin user (only if password is configured) if not settings.admin_password: print("ADMIN_PASSWORD not set - skipping admin user seed") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e5ffbcb..bb1a56f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ from sqlalchemy import ( Column, Integer, String, Float, DateTime, Boolean, - ForeignKey, Text, JSON, Index, UniqueConstraint + ForeignKey, Text, JSON, Index, UniqueConstraint, text ) from sqlalchemy.orm import relationship from datetime import datetime, timezone @@ -102,7 +102,7 @@ class Activity(Base): calories = Column(Float, nullable=True) training_stress_score = Column(Float, nullable=True) vo2max_estimate = Column(Float, nullable=True) - named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True) + named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True) polyline = Column(Text, nullable=True) bounding_box = Column(JSON, nullable=True) source_file = Column(String(512), nullable=True) @@ -199,8 +199,12 @@ class PersonalRecord(Base): is_current_record = Column(Boolean, default=True) __table_args__ = ( - UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record", - name="uq_pr_current"), + # Uniqueness is enforced at runtime by the partial index uq_pr_current_active + # (created in init_db), which only covers is_current_record=true rows. + # The old all-columns UniqueConstraint was dropped because it incorrectly + # constrained is_current_record=false rows too, causing multi-worker races. + Index("uq_pr_current_active", "user_id", "sport_type", "distance_m", + postgresql_where=text("is_current_record = true"), unique=True), ) diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index d934904..8d65129 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -47,15 +47,18 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str import garminconnect # Try stored OAuth token first. - # Must call login(tokenstore=...) rather than garth.loads() directly so that - # garmin.display_name is populated — it's required by get_user_summary() and - # several other endpoints. Without it every stats call silently returns None. + # Use garth.loads() directly (always treats the argument as an inline string). + # garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as + # filesystem paths and raising FileNotFoundError on every token-based auth attempt. + # After loads(), set display_name from the embedded profile — required by + # get_stats(), get_sleep_data(), and other endpoints that build URLs from it. if token_store: try: garmin = garminconnect.Garmin( email=email, password=decrypt_password(password_enc) ) - garmin.login(tokenstore=token_store) + garmin.garth.loads(token_store) + garmin.display_name = (garmin.garth.profile or {}).get("displayName", "") return garmin, None except Exception as exc: logger.info("Garmin token invalid (%s), re-authenticating", exc) @@ -76,7 +79,7 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime], lookback_days controls the start date on every sync: -1 → full history back to 2010 on first sync, then incremental (since-1d) - N → always look back N days from today (dedup prevents re-downloading) + N → incremental (since-1d) when since is set; else last N days on first sync Returns the number of new activities queued. """ import time @@ -87,6 +90,9 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime], if lookback_days == -1: # All-time: full pull on first sync, incremental thereafter start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) + elif since: + # Incremental: one day overlap to catch any late-arriving activities + start_date = (since - timedelta(days=1)).date() else: start_date = date.today() - timedelta(days=max(lookback_days, 1)) end_date = date.today() @@ -180,18 +186,22 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, lookback_days controls the window on every sync: -1 → full history back to 2010 on first sync, then incremental (since-1d) - N → always cover the last N days (upsert is safe to re-run) + N → incremental (since-1d) when since is set; else last N days on first sync Returns the number of days upserted. """ from sqlalchemy import text if lookback_days == -1: start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) + elif since: + # Incremental: one day overlap to catch any late-arriving wellness data + start_date = (since - timedelta(days=1)).date() else: start_date = date.today() - timedelta(days=max(lookback_days, 1)) days = (date.today() - start_date).days + 1 processed = 0 + import time as _time for i in range(max(days, 1)): day = start_date + timedelta(days=i) day_str = day.isoformat() @@ -199,6 +209,7 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, stats = _safe(garmin.get_stats, day_str) sleep_data = _safe(garmin.get_sleep_data, day_str) hrv_data = _safe(garmin.get_hrv_data, day_str) + _time.sleep(0.25) # avoid hammering Garmin's wellness API row = _parse_day(stats, sleep_data, hrv_data) if not row: