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 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}") # 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", ]: await conn.execute(text(stmt)) except Exception as e: print(f"health_metrics column migration skipped: {e}") # route_segments auto_generated column added after initial creation try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE" )) except Exception as e: print(f"route_segments column migration 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}") # 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.get("/health") async def healthcheck(): return {"status": "ok"}