from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from sqlalchemy import text import asyncio from app.core.database import engine, AsyncSessionLocal, Base from app.core.config import settings from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments async def init_db(): """Create tables then seed admin, with retries for slow DB startup. Multiple uvicorn workers may race here on first start. We tolerate duplicate table errors since they just mean another worker got there first. """ for attempt in range(15): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) break except Exception as e: msg = str(e).lower() if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg: print("Tables already created by another worker - skipping") break if attempt == 14: raise print(f"DB not ready yet (attempt {attempt + 1}/15): {e}") await asyncio.sleep(2) # Try TimescaleDB hypertable (non-fatal) try: async with engine.begin() as conn: await conn.execute(text( "SELECT create_hypertable('activity_data_points', 'timestamp', " "if_not_exists => TRUE, migrate_data => TRUE)" )) except Exception as e: print(f"TimescaleDB hypertable skipped: {e}") # Add columns that were introduced after initial table creation (non-fatal) try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE garmin_connect_configs " "ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30" )) except Exception as e: print(f"Column migration skipped: {e}") # activities.moving_time_s column added after initial creation (timer time) try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT" )) except Exception as e: print(f"activities.moving_time_s column migration skipped: {e}") # health_metrics columns added after initial creation try: async with engine.begin() as conn: for stmt in [ "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON", ]: await conn.execute(text(stmt)) except Exception as e: print(f"health_metrics column migration skipped: {e}") # biological_sex column on users added after initial creation try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)" )) except Exception as e: print(f"users.biological_sex column migration skipped: {e}") # pocketid_allowed_group column on users added after initial creation try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)" )) except Exception as e: print(f"users.pocketid_allowed_group column migration skipped: {e}") # goal_weight_kg column on users added after initial creation try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT" )) except Exception as e: print(f"users.goal_weight_kg column migration skipped: {e}") # Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days try: async with engine.begin() as conn: await conn.execute(text(""" UPDATE health_metrics SET avg_hr_day = sub.avg_hr, max_hr_day = sub.max_hr FROM ( SELECT id, AVG((elem->>1)::float) AS avg_hr, MAX((elem->>1)::float) AS max_hr FROM health_metrics, json_array_elements(intraday_hr) AS elem WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL) AND intraday_hr IS NOT NULL AND (elem->>1)::float > 0 GROUP BY id ) sub WHERE health_metrics.id = sub.id """)) except Exception as e: print(f"avg_hr_day backfill skipped: {e}") # Replace the all-columns unique constraint on personal_records with a partial # index (only current records must be unique per user/sport/distance). # The old constraint also covered is_current_record=False rows, causing # UniqueViolation crashes when multiple workers deactivate the same PR. try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE personal_records " "DROP CONSTRAINT IF EXISTS uq_pr_current" )) await conn.execute(text( "CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active " "ON personal_records (user_id, sport_type, distance_m) " "WHERE is_current_record = true" )) 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}") # Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) try: async with engine.begin() as conn: await conn.execute(text( "UPDATE activities SET avg_speed_ms = distance_m / duration_s " "WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0" )) await conn.execute(text( "UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s " "WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0" )) except Exception as e: print(f"avg_speed_ms fix skipped: {e}") # Seed admin user (only if password is configured) if not settings.admin_password: print("ADMIN_PASSWORD not set - skipping admin user seed") return from sqlalchemy import select from app.models.user import User from app.core.security import hash_password try: async with AsyncSessionLocal() as db: result = await db.execute( select(User).where(User.username == settings.admin_username) ) if not result.scalar_one_or_none(): admin = User( username=settings.admin_username, hashed_password=hash_password(settings.admin_password), is_admin=True, ) db.add(admin) await db.commit() print(f"Admin user '{settings.admin_username}' created") except Exception as e: msg = str(e).lower() if "duplicate" in msg or "unique" in msg: print("Admin user already exists - skipping seed") else: raise @asynccontextmanager async def lifespan(app: FastAPI): await init_db() yield app = FastAPI( title="MileVault", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["*"] if settings.environment == "development" else [], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(activities.router, prefix="/api/activities", tags=["activities"]) app.include_router(routes.router, prefix="/api/routes", tags=["routes"]) app.include_router(health.router, prefix="/api/health-metrics", tags=["health"]) app.include_router(records.router, prefix="/api/records", tags=["records"]) app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"]) app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(segments.router, prefix="/api/segments", tags=["segments"]) @app.get("/health") async def healthcheck(): return {"status": "ok"}