diff --git a/.env.example b/.env.example index 9d234ef..a1288a4 100644 --- a/.env.example +++ b/.env.example @@ -1,83 +1,88 @@ -# Fantasy Football API Configuration -# Copy this file to .env and add your actual API keys +# FFPy environment template +# +# Happy path +# ---------- +# cp .env.example .env # first-time setup (make bootstrap does this too) +# Edit .env — fill keys for the workflows you use (see REQUIRED notes below) +# make supabase.check # optional: verify Supabase URL/key before Fly deploy +# make fly.secrets # production: push Supabase + app URL vars to Fly +# +# Where secrets live (do not mix these up) +# ---------------------------------------- +# .env local dev; also the source for `make fly.secrets` +# Fly app secrets production runtime (set via make fly.secrets, not in git) +# GitHub secret FLY_API_TOKEN only — for CI auto-deploy on push to main +# Create with: make fly.token → add to GitHub → Environments → production +# +# Required keys by workflow +# ------------------------- +# Streamlit only (make run) nothing required — defaults are fine +# Web apps with auth (League / Pick'em) SUPABASE_URL + SUPABASE_PUBLISHABLE_KEY +# CFB fantasy pipeline CFBD_API_KEY +# Fly production deploy SUPABASE_URL + SUPABASE_PUBLISHABLE_KEY in .env, +# then make fly.secrets; FLY_API_TOKEN in GitHub only + +# ============================================================================= +# Core app +# ============================================================================= -# API Provider Selection -# Options: "espn" (free, no key needed), "sportsdata" (paid, more features) API_PROVIDER=espn - -# SportsDataIO API Key (Optional - Free tier: 1000 calls/month) -# Get your key at: https://sportsdata.io/ -# Choose NFL → Projections endpoint -SPORTSDATA_API_KEY=your_sportsdata_api_key_here - -# RapidAPI Key (Optional alternative) -# Get your key at: https://rapidapi.com/ -RAPIDAPI_KEY=your_rapidapi_key_here - -# NFL Season Configuration -NFL_SEASON=2024 # Use 2024 for historical projection model demo - -# Cache Settings (in seconds, default: 1 hour) +NFL_SEASON=2024 CACHE_TTL=3600 -# Database Configuration -DATABASE_PATH=~/.ffpy/ffpy.db # Default: user home directory -# Or use custom path: -# DATABASE_PATH=/custom/path/to/ffpy.db -DATABASE_TYPE=sqlite # Currently only sqlite supported - -# ESPN League Integration (Optional - for accessing your ESPN league) -# Get these values from your browser after logging into ESPN Fantasy -# See docs/ESPN_API_INTEGRATION_GUIDE.md for detailed instructions - -# Your ESPN League ID (from URL: fantasy.espn.com/football/league?leagueId=XXXXXX) -ESPN_LEAGUE_ID= - -# For PRIVATE leagues only (not needed for public leagues): -# SWID Cookie (looks like: {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}) -ESPN_SWID= - -# ESPN_S2 Cookie (long string, ~200 characters) -ESPN_S2= +DATABASE_PATH=~/.ffpy/ffpy.db +DATABASE_TYPE=sqlite -# ------------------------------------------------------------------- -# Web Demo Auth / Anti-Abuse (planned Supabase deployment scaffold) -# These are not used by the current local app yet. -# ------------------------------------------------------------------- +# ============================================================================= +# Web apps — League Manager & Pick'em (REQUIRED when WEB_AUTH_ENABLED=true) +# ============================================================================= -# Public app URL (used by Supabase email confirmation redirects) -# Production: https://ffpy-pickem.fly.dev Local: http://localhost:8080 -PUBLIC_APP_URL=http://localhost:8080 WEB_AUTH_ENABLED=false +PUBLIC_APP_URL=http://localhost:8080 -# Supabase project settings +# Supabase → Project Settings → API SUPABASE_URL= -# Preferred for new Supabase projects. Settings -> API Keys -> Publishable key. +# Preferred. Settings → API Keys → Publishable key. SUPABASE_PUBLISHABLE_KEY= -# Legacy fallback for older projects. +# Legacy fallback if your project has no publishable key yet. SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= -# Legacy/local HS256 token support only. Leave blank to verify Supabase JWT signing keys through JWKS. + +# Optional Supabase overrides (usually leave blank) SUPABASE_JWT_SECRET= -# Optional override. Default is /auth/v1/.well-known/jwks.json. SUPABASE_JWKS_URL= SUPABASE_JWT_AUDIENCE=authenticated SUPABASE_FETCH_USER_ON_VERIFY=true +# Server-only admin key — never push to Fly via make fly.secrets; local tooling only. +SUPABASE_SERVICE_ROLE_KEY= + +# ============================================================================= +# Optional API keys +# ============================================================================= + +# SportsDataIO — https://sportsdata.io/ +SPORTSDATA_API_KEY= + +# RapidAPI — https://rapidapi.com/ +RAPIDAPI_KEY= + +# CollegeFootballData — https://collegefootballdata.com/key (REQUIRED for CFB pipeline) +CFBD_API_KEY= + +# ESPN private leagues only — see docs/integration/espn.md +ESPN_LEAGUE_ID= +ESPN_SWID= +ESPN_S2= + +# ============================================================================= +# Optional — anti-abuse scaffold (not required for local dev today) +# ============================================================================= -# Cloudflare Turnstile TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= - -# Upstash Redis for rate limiting UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= - -# Abuse monitoring and quotas ABUSE_HASH_SALT= SESSION_COOKIE_SECURE=false MAX_BACKTESTS_PER_HOUR=10 MAX_COMPARES_PER_HOUR=3 MAX_DAILY_COST_UNITS=100 - -# CollegeFootballData API (CFB fantasy stats — https://collegefootballdata.com) -CFBD_API_KEY=your_cfbd_api_key_here diff --git a/QUICKSTART.md b/QUICKSTART.md index 32246cb..fc8a89b 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -8,7 +8,21 @@ Three commands get you running with database-backed app data. Works on Linux, ma make bootstrap ``` -This installs `uv`, syncs Python dependencies, seeds `.env` from the template, and creates the SQLite database. Safe to re-run any time. +This installs `uv`, syncs Python dependencies, seeds `.env` from `.env.example`, and creates the SQLite database. Safe to re-run any time. + +To create `.env` manually instead: `cp .env.example .env`. Open `.env.example` for **required keys by workflow** (Streamlit vs web auth vs CFB vs Fly deploy). + +### Web app auth (League Manager / Pick'em) + +```bash +cp .env.example .env # skip if make bootstrap already created .env +# Edit .env: SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, WEB_AUTH_ENABLED=true +make supabase.check # verify keys before deploying +make fly.secrets # push Supabase vars to Fly (production) +make fly.deploy # or merge to main for CI auto-deploy +``` + +`FLY_API_TOKEN` goes in GitHub (not `.env`) — see [docs/deployment/fly.md](docs/deployment/fly.md). ## Generate app data @@ -137,4 +151,4 @@ Run all commands from **WSL** (Ubuntu recommended). `make` and `bash` need to be - **`command not found: uv` right after bootstrap** — open a new shell, or `source ~/.local/bin/env`, then re-run `make bootstrap`. - **Port 8501 already in use** — `make run PORT=8502`. - **Browser doesn't open** — navigate to `http://localhost:8501` manually. -- **Need API keys** — edit `.env` (see `.env.example` for the list). +- **Need API keys** — `cp .env.example .env` if missing, then edit `.env` (required keys are documented at the top of `.env.example`). diff --git a/README.md b/README.md index f86a636..96fe245 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,24 @@ All database targets wrap the `ffpy-db` CLI — `uv run ffpy-db --help` for the ## Configuration -Copy and edit `.env`: +First-time setup copies the template automatically (`make bootstrap`). To create or reset it manually: ```bash cp .env.example .env ``` -Key settings: `API_PROVIDER` (espn/sportsdata), `NFL_SEASON`, `DATABASE_PATH`. +Then edit `.env` for the workflows you use. The template lists **required keys by workflow** at the top. + +| Workflow | Required in `.env` | +|----------|-------------------| +| Streamlit only (`make run`) | Nothing — defaults work | +| League / Pick'em web apps with auth | `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` | +| CFB fantasy pipeline | `CFBD_API_KEY` | +| Fly production deploy | `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, then `make fly.secrets` | + +**Where secrets go:** `.env` for local dev (and as the source for `make fly.secrets`); Fly app secrets for production runtime; `FLY_API_TOKEN` in GitHub only (not `.env`) for CI auto-deploy. See [docs/deployment/fly.md](docs/deployment/fly.md). + +Verify Supabase before deploying: `make supabase.check` ## Features diff --git a/docs/deployment/fly.md b/docs/deployment/fly.md index 47a9552..f732b45 100644 --- a/docs/deployment/fly.md +++ b/docs/deployment/fly.md @@ -53,27 +53,41 @@ Do not create extra volumes expecting SQLite data to replicate automatically. Mu - add a real SQLite replication layer such as LiteFS - run separate regional apps with an explicit data sync/restore process -Set the runtime secrets after the app exists. First create a local `.env` file; it is ignored by git: +## Environment happy path + +`make bootstrap` seeds `.env` from `.env.example` on first run. To do it manually: ```bash cp .env.example .env ``` -Populate these values in `.env` from your Supabase dashboard: +### 1. Fill required keys in `.env` -- `SUPABASE_URL`: Project Settings -> API -> Project URL. It looks like `https://.supabase.co`. -- `SUPABASE_PUBLISHABLE_KEY`: Project Settings -> API Keys -> Publishable key. This is preferred for new Supabase projects. +From your Supabase dashboard (Project Settings → API): -Legacy fallback: +| Variable | Where to find it | Required | +|----------|------------------|----------| +| `SUPABASE_URL` | Project URL — `https://.supabase.co` | Yes | +| `SUPABASE_PUBLISHABLE_KEY` | API Keys → Publishable key | Yes (preferred) | +| `SUPABASE_ANON_KEY` | Legacy API Keys → anon key | Only if no publishable key | +| `PUBLIC_APP_URL` | Set to `https://ffpy-pickem.fly.dev` for production secrets | Yes for prod | -- `SUPABASE_ANON_KEY`: Project Settings -> API Keys -> Legacy API Keys -> anon key. Use this only if your project has not moved to publishable keys yet. +Optional in `.env` (usually leave blank): -Optional: +- `SUPABASE_JWKS_URL` — defaults to `/auth/v1/.well-known/jwks.json` +- `SUPABASE_JWT_SECRET` — local HS256 dev tokens only; production uses JWKS -- `SUPABASE_JWKS_URL`: override for the signing-key discovery endpoint. Usually leave blank; the app defaults to `/auth/v1/.well-known/jwks.json`. -- `SUPABASE_JWT_SECRET`: legacy/local HS256 support only. Set this if your Supabase project still uses legacy JWT-secret verification or if you want to mint local dev tokens. If you leave it empty, the backend verifies Supabase access tokens with the project's JWT signing keys through JWKS. +Do **not** put `FLY_API_TOKEN` in `.env` — that token is for GitHub Actions only (see [Deploy via CI](#deploy-via-ci) below). -Then push the required runtime secrets to Fly: +### 2. Verify locally + +```bash +make supabase.check +``` + +### 3. Push runtime secrets to Fly + +After the Fly app exists: ```bash make fly.secrets @@ -105,13 +119,19 @@ make fly.logs The GitHub Actions workflow in [.github/workflows/ci-cd.yml](../../.github/workflows/ci-cd.yml) runs lint + tests + Docker build on every PR and push to `main`, then deploys to Fly only on `main`. -Create a deploy token for GitHub Actions: +### Deploy via CI + +Merges to `main` auto-deploy when CI passes. Create a deploy token once: ```bash make fly.token ``` -Add the token to the GitHub repository secret named `FLY_API_TOKEN`. +Add the printed token to GitHub → **Settings** → **Environments** → **production** → **Environment secrets** → `FLY_API_TOKEN`. + +(Repo-level **Actions secrets** also works; the workflow uses the `production` environment.) + +Manual deploy from your machine does not need this token — use `fly auth login` and `make fly.deploy`. ## Supabase auth redirects (production) diff --git a/fly.toml b/fly.toml index 3eb5cb0..8d2c43f 100644 --- a/fly.toml +++ b/fly.toml @@ -1,6 +1,11 @@ app = "ffpy-pickem" primary_region = "iad" +[vm] + memory = "512mb" + cpu_kind = "shared" + cpus = 1 + [build] dockerfile = "Dockerfile" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 36880fd..7a54f76 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -46,7 +46,10 @@ uv run ffpy-db migrate echo info "Bootstrap complete." -echo " Start the app: make run" +echo " Start Streamlit: make run" echo " Generate app data: make data" +echo " Configure secrets: edit .env (seeded from .env.example — see REQUIRED notes in that file)" +echo " Verify Supabase: make supabase.check" +echo " Deploy to Fly: docs/deployment/fly.md" echo " Lint before pushing: make precommit (also runs on git commit)" echo " See all make targets: make help" diff --git a/src/ffpy/database.py b/src/ffpy/database.py index 0e2701a..a65c6b0 100644 --- a/src/ffpy/database.py +++ b/src/ffpy/database.py @@ -72,6 +72,7 @@ def __init__(self, db_path: Optional[str] = None): # Connect to database (check_same_thread=False for FastAPI/uvicorn thread safety) self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) self.conn.row_factory = sqlite3.Row # Access columns by name + self.conn.execute("PRAGMA foreign_keys = ON") # enable ON DELETE CASCADE # Initialize schema self.init_database() @@ -3775,9 +3776,10 @@ def get_matchups_for_league(self, league_id: str, week: int) -> list[dict]: return [dict(row) for row in cursor.fetchall()] def delete_user_league(self, league_id: str, user_id: str) -> None: - """Delete a league and its teams/matchups (CASCADE).""" - self.conn.execute( + """Delete a league owned by ``user_id``; teams/matchups cascade.""" + cursor = self.conn.execute( "DELETE FROM user_leagues WHERE league_id = ? AND user_id = ?", (league_id, user_id), ) - self.conn.commit() + if cursor.rowcount: + self.conn.commit() diff --git a/src/ffpy/draft_strategy.py b/src/ffpy/draft_strategy.py index c86a201..171fdd3 100644 --- a/src/ffpy/draft_strategy.py +++ b/src/ffpy/draft_strategy.py @@ -28,6 +28,7 @@ import logging import time from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Dict, List, Optional import numpy as np @@ -86,19 +87,41 @@ def _map_position(pos: Optional[str]) -> str: return pos +def _sleeper_players_cache_path() -> Path: + from ffpy.config import Config + + cache_dir = Path(Config.DATABASE_PATH).expanduser().parent / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / "sleeper_players.json" + + def load_sleeper_players(force: bool = False) -> Dict[str, Any]: - """Return the Sleeper player map, cached at module level. + """Return the Sleeper player map, cached in memory and on disk. - The Sleeper ``/players/nfl`` payload is large (~5 MB); caching avoids - refetching on every request. + The Sleeper ``/players/nfl`` payload is large (~15 MB). League import and + draft-help call this to resolve player names; the disk cache (6-hour TTL) + limits refetches. Hosts below ~512 MB RAM may still struggle on a cold load. """ now = time.time() cached = _SLEEPER_PLAYERS_CACHE.get("data") if not force and cached is not None and (now - _SLEEPER_PLAYERS_CACHE["fetched_at"]) < _SLEEPER_CACHE_TTL: return cached + + cache_path = _sleeper_players_cache_path() + if not force and cache_path.exists(): + age = now - cache_path.stat().st_mtime + if age < _SLEEPER_CACHE_TTL: + with cache_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + _SLEEPER_PLAYERS_CACHE["data"] = data + _SLEEPER_PLAYERS_CACHE["fetched_at"] = now + return data + from ffpy.integrations.sleeper import SleeperIntegration data = SleeperIntegration.get_players() + with cache_path.open("w", encoding="utf-8") as handle: + json.dump(data, handle) _SLEEPER_PLAYERS_CACHE["data"] = data _SLEEPER_PLAYERS_CACHE["fetched_at"] = now return data diff --git a/src/ffpy/league_api.py b/src/ffpy/league_api.py index 48b9cc0..05df036 100644 --- a/src/ffpy/league_api.py +++ b/src/ffpy/league_api.py @@ -62,6 +62,18 @@ class DraftHelpRequest(BaseModel): num_players: int = Field(100, ge=1, le=200) pick_slots: Optional[List[int]] = None num_teams: int = Field(10, ge=4, le=20) + draft_order: Optional[List[str]] = None # team_ids in 1st-round pick order + + +def _compute_snake_pick_slots(position: int, num_teams: int, num_rounds: int = 3) -> List[int]: + """Return snake-draft pick numbers for the team at ``position`` (1-indexed).""" + slots = [] + for r in range(1, num_rounds + 1): + if r % 2 == 1: + slots.append((r - 1) * num_teams + position) + else: + slots.append(r * num_teams - position + 1) + return slots # --------------------------------------------------------------------------- @@ -247,30 +259,40 @@ def _import_from_yahoo(league_id: str, season: int, creds: dict) -> dict: def _import_from_sleeper(league_id: str, season: int) -> dict: from collections import defaultdict - from ffpy.draft_strategy import load_sleeper_players - league = SleeperIntegration.get_league(league_id) rosters = SleeperIntegration.get_rosters(league_id) users = SleeperIntegration.get_league_users(league_id) user_by_id = {u.get("user_id"): u for u in users} + # Load the Sleeper player map to resolve names/positions/teams immediately. + # The map is cached in memory and on disk (6-hour TTL) by load_sleeper_players(), + # so the ~15 MB payload is fetched at most once per session. + from ffpy.draft_strategy import load_sleeper_players + players_map = load_sleeper_players() teams = [] - for r in rosters: - roster_id = r.get("roster_id") + for idx, r in enumerate(rosters): + players_list = r.get("players") or [] owner_id = r.get("owner_id") or "" + # Skip unclaimed roster slots — no owner and no players means a + # placeholder that Sleeper returns for unused / future slots. + if not owner_id and not players_list: + continue + roster_id = r.get("roster_id") user = user_by_id.get(owner_id, {}) metadata = user.get("metadata") or {} + fallback_id = str(roster_id) if roster_id is not None else owner_id or str(idx + 1) team_name = ( metadata.get("team_name") or user.get("display_name") - or (f"Team {roster_id}" if roster_id is not None else "Unknown") + or (f"Team {fallback_id}" if fallback_id else "Unknown") ) - owner_display = user.get("display_name") or owner_id + owner_display = user.get("display_name") or owner_id or "Unknown" + team_id_suffix = str(roster_id) if roster_id is not None else owner_id or str(idx + 1) teams.append( { - "team_id": f"sleeper:{league_id}:{roster_id}", + "team_id": f"sleeper:{league_id}:{team_id_suffix}", "name": team_name, "owner": owner_display, "wins": r.get("settings", {}).get("wins", 0), @@ -323,7 +345,7 @@ def _import_from_sleeper(league_id: str, season: int) -> dict: "season": league.get("season", season), "scoring_type": "custom", "roster_size": None, - "num_teams": league.get("total_rosters"), + "num_teams": len(teams), "playoff_teams": league.get("settings", {}).get("playoff_teams"), }, "teams": teams, @@ -516,7 +538,7 @@ def discover_sleeper_leagues( raise HTTPException(status_code=502, detail=f"Sleeper API error: {exc}") from exc return [ { - "league_id": lg.get("league_id"), + "league_id": str(lg.get("league_id") or ""), "name": lg.get("name"), "season": lg.get("season"), "status": lg.get("status"), @@ -541,14 +563,28 @@ def import_league( else: creds = {} - if payload.provider == "espn": - data = _import_from_espn(payload.league_id, payload.season, creds) - elif payload.provider == "yahoo": - data = _import_from_yahoo(payload.league_id, payload.season, creds) - elif payload.provider == "sleeper": - data = _import_from_sleeper(payload.league_id, payload.season) - else: - raise HTTPException(status_code=400, detail="Unsupported provider") + try: + if payload.provider == "espn": + data = _import_from_espn(payload.league_id, payload.season, creds) + elif payload.provider == "yahoo": + data = _import_from_yahoo(payload.league_id, payload.season, creds) + elif payload.provider == "sleeper": + data = _import_from_sleeper(payload.league_id, payload.season) + else: + raise HTTPException(status_code=400, detail="Unsupported provider") + except HTTPException: + raise + except Exception as exc: + logger.exception( + "League import failed provider=%s league_id=%s season=%s", + payload.provider, + payload.league_id, + payload.season, + ) + raise HTTPException( + status_code=502, + detail="Import failed. Check the league ID and provider credentials, then try again.", + ) from exc store_user_id = user.user_id if payload.provider == "sleeper" and payload.sleeper_username: @@ -630,8 +666,18 @@ def draft_help( num_teams = payload.num_teams or int(league.get("num_teams") or 10) pick_slots = payload.pick_slots + + # If a full draft order is provided, compute snake pick slots from the + # user's position in the order. Explicit pick_slots still take precedence. + if not pick_slots and payload.draft_order: + try: + pos = payload.draft_order.index(payload.team_id) + 1 + pick_slots = _compute_snake_pick_slots(pos, num_teams) + except ValueError: + raise HTTPException(status_code=400, detail="Your team is not in the draft order") + if not pick_slots and num_teams: - # Default snake turn for pick #1 in a 3-round draft. + # Fallback: default snake turn for pick #1 in a 3-round draft. pick_slots = [1, 2 * num_teams, 2 * num_teams + 1] config = DraftStrategyConfig(num_teams=num_teams, pick_slots=pick_slots) diff --git a/src/ffpy/web/league_app/app.js b/src/ffpy/web/league_app/app.js index d3c9bd4..6934ea3 100644 --- a/src/ffpy/web/league_app/app.js +++ b/src/ffpy/web/league_app/app.js @@ -12,6 +12,7 @@ createApp({ College