feat: Feature 1.2 (NBA stats FastAPI service) + Feature 1.4 (database schema)
Feature 1.2: Python FastAPI microservice wrapping nba_api - GET /stats/season-avg, /stats/last-n, /stats/splits, /players/search - Redis caching (24hr/1hr/6hr/7day), 0.6s rate limiting, PRA derived stat - 27 Python tests passing Feature 1.4: Complete Supabase database schema - 6 tables: users, picks, scan_sessions, bets, outcomes, performance - RLS enabled on all tables with auth.uid() policies - 3 triggers: auto-create user, updated_at, scan count reset - 37 schema validation tests passing - Migration SQL ready, pending manual apply (WSL2 DNS blocker) Total: 92 tests (65 Node.js + 27 Python), all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,10 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.temp/
|
||||||
|
|||||||
+12
-5
@@ -10,10 +10,17 @@
|
|||||||
**Owner:** Kev
|
**Owner:** Kev
|
||||||
|
|
||||||
## BLOCKER-002: Auth Provider
|
## BLOCKER-002: Auth Provider
|
||||||
|
**Status:** RESOLVED (2026-03-21)
|
||||||
|
**Decision:** Supabase Auth. See DECISION-004.
|
||||||
|
|
||||||
|
## BLOCKER-003: WSL2 Cannot Resolve *.supabase.co Domains
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
**Impact:** Blocks user system (Feature 1.4+)
|
**Impact:** Blocks applying Feature 1.4 migration and running verify-schema.js from CLI
|
||||||
**Options:**
|
**Details:**
|
||||||
- Clerk — better DX, faster setup, more expensive at scale
|
- WSL2 DNS resolver (10.255.255.254) cannot resolve `*.supabase.co` TLD
|
||||||
- Supabase Auth — already in stack, free tier generous, slightly more setup
|
- Google DNS (8.8.8.8) returns NXDOMAIN — may be a new project with DNS propagation delay
|
||||||
**Decision needed by:** Before Feature 1.4 implementation
|
- `*.supabase.com` (e.g., pooler, api) resolves fine
|
||||||
|
- Migration SQL is written and validated (37 tests pass)
|
||||||
|
**Workaround:** Apply migration via Supabase Dashboard SQL Editor
|
||||||
|
**Resolution path:** Either DNS propagates, or add Supabase IP to /etc/hosts, or use `supabase link` with access token via api.supabase.com
|
||||||
**Owner:** Kev
|
**Owner:** Kev
|
||||||
|
|||||||
+39
-4
@@ -16,14 +16,35 @@ Phase 1 — Foundation
|
|||||||
- Quota tracking via response headers, 429 when exhausted
|
- Quota tracking via response headers, 429 when exhausted
|
||||||
- Query filters: stat_type, player (partial match), book
|
- Query filters: stat_type, player (partial match), book
|
||||||
- 28 tests passing (18 unit, 10 integration)
|
- 28 tests passing (18 unit, 10 integration)
|
||||||
- Known limitation: player-to-team assignment deferred to Feature 1.2 (uses home_team/away_team instead of team/opponent)
|
- Known limitation: player-to-team assignment deferred to Feature 1.2
|
||||||
|
|
||||||
|
### Feature 1.2 — NBA_API Stats Wrapper (COMPLETE)
|
||||||
|
- FastAPI microservice in nba-service/ on port 8000
|
||||||
|
- GET /stats/season-avg — season averages (24hr cache)
|
||||||
|
- GET /stats/last-n — last N game averages (1hr cache)
|
||||||
|
- GET /stats/splits — home/away, B2B/rest days, vs-team (6hr cache)
|
||||||
|
- GET /players/search — partial name to player ID (7-day cache)
|
||||||
|
- PRA computed as derived stat
|
||||||
|
- 0.6s rate limiting between nba_api calls with retry
|
||||||
|
- 27 tests passing (16 unit, 11 integration)
|
||||||
|
- Startup script: scripts/start.sh runs both Node + Python services
|
||||||
|
|
||||||
|
### Feature 1.4 — Database Schema (CODE COMPLETE — pending Supabase apply)
|
||||||
|
- Migration SQL: supabase/migrations/001_initial_schema.sql
|
||||||
|
- 6 tables: users, picks, scan_sessions, bets, outcomes, performance
|
||||||
|
- RLS enabled on all tables with auth.uid() policies
|
||||||
|
- 3 triggers: auto-create user, updated_at, scan count reset
|
||||||
|
- All constraints, indexes, and FKs defined
|
||||||
|
- 37 schema validation tests passing
|
||||||
|
- BLOCKED: WSL2 cannot resolve *.supabase.co — needs manual apply via SQL Editor
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
Feature 1.2 — NBA_API Stats Wrapper (no dependencies, can build now)
|
- Apply Feature 1.4 migration to Supabase (manual via SQL Editor)
|
||||||
Feature 1.4 — Database Schema (no dependencies, can build parallel)
|
- Run verify-schema.js to confirm tables exist
|
||||||
|
- Feature 1.3 — Prop Analysis Engine (depends: 1.1 + 1.2)
|
||||||
|
|
||||||
## Active Blockers
|
## Active Blockers
|
||||||
See BLOCKERS.md
|
- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co (see BLOCKERS.md)
|
||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
@@ -34,3 +55,17 @@ See BLOCKERS.md
|
|||||||
- Logged DECISION-001 (API response format) and DECISION-002 (credit conservation)
|
- Logged DECISION-001 (API response format) and DECISION-002 (credit conservation)
|
||||||
- Spec updated: home_team/away_team replaces team/opponent (API limitation)
|
- Spec updated: home_team/away_team replaces team/opponent (API limitation)
|
||||||
- Credits used: 2 of 500 (498 remaining)
|
- Credits used: 2 of 500 (498 remaining)
|
||||||
|
|
||||||
|
### Session 2 — 2026-03-21
|
||||||
|
- Built Feature 1.2: FastAPI microservice wrapping nba_api
|
||||||
|
- stats.py, player_map.py, cache.py, main.py, config.py
|
||||||
|
- 27 Python tests, all passing
|
||||||
|
- Built Feature 1.4: Full database schema SQL
|
||||||
|
- 6 tables, RLS, triggers, indexes, constraints
|
||||||
|
- 37 schema validation tests, all passing
|
||||||
|
- Could not apply to Supabase (DNS blocker)
|
||||||
|
- Logged DECISION-003 (Python microservice) and DECISION-004 (Supabase Auth)
|
||||||
|
- Created startup script (scripts/start.sh) for both services
|
||||||
|
- Created Supabase client module (src/utils/supabase.js)
|
||||||
|
- Created schema verification script (scripts/verify-schema.js)
|
||||||
|
- Total tests: 92 (65 Node.js + 27 Python), all passing
|
||||||
|
|||||||
@@ -69,3 +69,21 @@ Outcome level (nested under market.outcomes[]):
|
|||||||
- Decision: On-demand fetching only. Never poll. Cache aggressively at 15-min TTL. Batch all markets into one call per event. For a full NBA slate, one refresh = ~10 credits. At 15-min cache, even heavy usage stays under budget.
|
- Decision: On-demand fetching only. Never poll. Cache aggressively at 15-min TTL. Batch all markets into one call per event. For a full NBA slate, one refresh = ~10 credits. At 15-min cache, even heavy usage stays under budget.
|
||||||
- Alternatives considered: Background polling every 15 min — rejected, would burn ~480 credits per game day.
|
- Alternatives considered: Background polling every 15 min — rejected, would burn ~480 credits per game day.
|
||||||
- Consequences: First request after cache expires will be slower (live API call). Acceptable tradeoff for free tier.
|
- Consequences: First request after cache expires will be slower (live API call). Acceptable tradeoff for free tier.
|
||||||
|
|
||||||
|
### DECISION-003: Python Microservice for nba_api (Feature 1.2)
|
||||||
|
- Date: 2026-03-21
|
||||||
|
- Context: nba_api is a Python library. Backend is Node.js/Express. Need a bridge.
|
||||||
|
- Decision: FastAPI microservice. Node calls it via internal HTTP. Python stays idiomatic, caching strategy (24hr season averages, 1hr recent games) lives in the Python service with its own Redis connection.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Python child process (Node spawns python3, parses stdout) — rejected: cold start on every call, awkward error handling.
|
||||||
|
- Pre-fetch cron (Python writes to Redis on schedule, Node reads) — rejected: more moving parts, stale data risk.
|
||||||
|
- Consequences: Two processes to run (Node + Python). Need a startup script or docker-compose. Internal port convention: Node on 3000, Python on 8000.
|
||||||
|
|
||||||
|
### DECISION-004: Supabase Auth + RLS (Feature 1.4)
|
||||||
|
- Date: 2026-03-21
|
||||||
|
- Context: Need auth provider for user system. Supabase already in stack.
|
||||||
|
- Decision: Supabase Auth. RLS enabled at project level. Our `users` table extends `auth.users` via FK on `id`. All tables use RLS policies scoped to `auth.uid()`.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Clerk — better DX but adds a vendor, costs more at scale.
|
||||||
|
- Auth-agnostic (own users table, plug in later) — rejected: delays RLS setup, more migration work later.
|
||||||
|
- Consequences: All table access goes through RLS. Service role key bypasses RLS for admin/backend operations. Anon key used for client-side auth flows.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379")
|
||||||
|
|
||||||
|
# Cache TTLs in seconds
|
||||||
|
SEASON_AVG_TTL = 86400 # 24 hours
|
||||||
|
LAST_N_TTL = 3600 # 1 hour
|
||||||
|
SPLITS_TTL = 21600 # 6 hours
|
||||||
|
PLAYER_SEARCH_TTL = 604800 # 7 days
|
||||||
|
|
||||||
|
# nba_api rate limiting
|
||||||
|
NBA_API_DELAY = 0.6 # seconds between calls
|
||||||
|
NBA_API_RETRY_DELAY = 2.0
|
||||||
|
NBA_API_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Service
|
||||||
|
PORT = int(os.getenv("NBA_SERVICE_PORT", "8000"))
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from app.services.stats import get_season_avg, get_last_n, get_splits
|
||||||
|
from app.utils.player_map import search_players
|
||||||
|
from app.utils.cache import cache_health
|
||||||
|
|
||||||
|
app = FastAPI(title="BetonBLK NBA Stats Service", version="1.0.0")
|
||||||
|
|
||||||
|
VALID_STAT_TYPES = {
|
||||||
|
"points", "rebounds", "assists", "threes", "blocks",
|
||||||
|
"steals", "pra", "turnovers", "minutes", "games_played",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_SPLIT_TYPES = {"home_away", "rest_days", "vs_team"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "cache": "connected" if cache_health() else "disconnected"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/players/search")
|
||||||
|
async def player_search(name: str = Query(..., min_length=2)):
|
||||||
|
results = search_players(name)
|
||||||
|
if not results:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Player not found: {name}")
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stats/season-avg")
|
||||||
|
async def season_avg(
|
||||||
|
player: str = Query(..., min_length=2),
|
||||||
|
stat_type: str = Query(None),
|
||||||
|
season: str = Query(None),
|
||||||
|
):
|
||||||
|
if stat_type and stat_type not in VALID_STAT_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid stat_type: {stat_type}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = get_season_avg(player, stat_type=stat_type, season=season)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail="NBA stats service unavailable")
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Player not found: {player}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stats/last-n")
|
||||||
|
async def last_n(
|
||||||
|
player: str = Query(..., min_length=2),
|
||||||
|
n: int = Query(10, ge=1, le=30),
|
||||||
|
stat_type: str = Query(None),
|
||||||
|
):
|
||||||
|
if stat_type and stat_type not in VALID_STAT_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid stat_type: {stat_type}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = get_last_n(player, n=n, stat_type=stat_type)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail="NBA stats service unavailable")
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Player not found: {player}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stats/splits")
|
||||||
|
async def splits(
|
||||||
|
player: str = Query(..., min_length=2),
|
||||||
|
stat_type: str = Query(...),
|
||||||
|
split_type: str = Query(...),
|
||||||
|
opponent: str = Query(None),
|
||||||
|
):
|
||||||
|
if stat_type not in VALID_STAT_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid stat_type: {stat_type}")
|
||||||
|
|
||||||
|
if split_type not in VALID_SPLIT_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid split_type: {split_type}")
|
||||||
|
|
||||||
|
if split_type == "vs_team" and not opponent:
|
||||||
|
raise HTTPException(status_code=400, detail="opponent is required when split_type=vs_team")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = get_splits(player, stat_type, split_type, opponent=opponent)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail="NBA stats service unavailable")
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Player not found: {player}")
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from nba_api.stats.endpoints import playercareerstats, playergamelog
|
||||||
|
from nba_api.stats.library.parameters import SeasonAll
|
||||||
|
|
||||||
|
from app.utils.cache import cache_get, cache_set
|
||||||
|
from app.utils.player_map import resolve_player
|
||||||
|
from app.config import (
|
||||||
|
SEASON_AVG_TTL, LAST_N_TTL, SPLITS_TTL, NBA_API_DELAY,
|
||||||
|
NBA_API_RETRY_DELAY, NBA_API_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map nba_api columns to our internal stat names
|
||||||
|
STAT_MAP = {
|
||||||
|
"PTS": "points",
|
||||||
|
"REB": "rebounds",
|
||||||
|
"AST": "assists",
|
||||||
|
"FG3M": "threes",
|
||||||
|
"BLK": "blocks",
|
||||||
|
"STL": "steals",
|
||||||
|
"TOV": "turnovers",
|
||||||
|
"MIN": "minutes",
|
||||||
|
"GP": "games_played",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_season():
|
||||||
|
"""Return current NBA season string (e.g., '2025-26'). Season starts in October."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
year = now.year if now.month >= 10 else now.year - 1
|
||||||
|
return f"{year}-{str(year + 1)[-2:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _call_nba_api(fn, **kwargs):
|
||||||
|
"""Call nba_api with rate limiting and retry."""
|
||||||
|
time.sleep(NBA_API_DELAY)
|
||||||
|
try:
|
||||||
|
return fn(**kwargs, timeout=NBA_API_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
time.sleep(NBA_API_RETRY_DELAY)
|
||||||
|
return fn(**kwargs, timeout=NBA_API_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_stats(row):
|
||||||
|
"""Convert a single nba_api stats row to our internal format."""
|
||||||
|
stats = {}
|
||||||
|
for nba_col, our_name in STAT_MAP.items():
|
||||||
|
val = row.get(nba_col)
|
||||||
|
if val is not None:
|
||||||
|
stats[our_name] = round(float(val), 1)
|
||||||
|
# Compute PRA
|
||||||
|
pts = stats.get("points", 0)
|
||||||
|
reb = stats.get("rebounds", 0)
|
||||||
|
ast = stats.get("assists", 0)
|
||||||
|
stats["pra"] = round(pts + reb + ast, 1)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_team(career_data, season):
|
||||||
|
"""Extract team abbreviation from career stats for given season."""
|
||||||
|
rows = career_data.get_data_frames()[0]
|
||||||
|
season_row = rows[rows["SEASON_ID"] == season]
|
||||||
|
if not season_row.empty:
|
||||||
|
return season_row.iloc[0]["TEAM_ABBREVIATION"]
|
||||||
|
if not rows.empty:
|
||||||
|
return rows.iloc[-1]["TEAM_ABBREVIATION"]
|
||||||
|
return "UNK"
|
||||||
|
|
||||||
|
|
||||||
|
def get_season_avg(player_name, stat_type=None, season=None):
|
||||||
|
"""Get a player's season averages."""
|
||||||
|
player_id, full_name = resolve_player(player_name)
|
||||||
|
if player_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if season is None:
|
||||||
|
season = get_current_season()
|
||||||
|
|
||||||
|
cache_key = f"nba:season:{player_id}:{season}"
|
||||||
|
cached = cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
result = cached
|
||||||
|
result["source"] = "cache"
|
||||||
|
if stat_type and stat_type in result["stats"]:
|
||||||
|
result["stats"] = {stat_type: result["stats"][stat_type]}
|
||||||
|
return result
|
||||||
|
|
||||||
|
career = _call_nba_api(playercareerstats.PlayerCareerStats, player_id=player_id)
|
||||||
|
df = career.get_data_frames()[0]
|
||||||
|
season_row = df[df["SEASON_ID"] == season]
|
||||||
|
|
||||||
|
if season_row.empty:
|
||||||
|
return {
|
||||||
|
"player": full_name,
|
||||||
|
"player_id": player_id,
|
||||||
|
"team": "UNK",
|
||||||
|
"season": season,
|
||||||
|
"source": "live",
|
||||||
|
"stats": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
row = season_row.iloc[0].to_dict()
|
||||||
|
team = row.get("TEAM_ABBREVIATION", "UNK")
|
||||||
|
stats = _map_stats(row)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"player": full_name,
|
||||||
|
"player_id": player_id,
|
||||||
|
"team": team,
|
||||||
|
"season": season,
|
||||||
|
"source": "live",
|
||||||
|
"stats": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_set(cache_key, result, SEASON_AVG_TTL)
|
||||||
|
|
||||||
|
if stat_type and stat_type in result["stats"]:
|
||||||
|
result_filtered = dict(result)
|
||||||
|
result_filtered["stats"] = {stat_type: result["stats"][stat_type]}
|
||||||
|
return result_filtered
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_n(player_name, n=10, stat_type=None):
|
||||||
|
"""Get a player's averages over their last N games."""
|
||||||
|
player_id, full_name = resolve_player(player_name)
|
||||||
|
if player_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
n = min(max(n, 1), 30)
|
||||||
|
|
||||||
|
cache_key = f"nba:last:{player_id}:{n}"
|
||||||
|
cached = cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
result = cached
|
||||||
|
result["source"] = "cache"
|
||||||
|
if stat_type and stat_type in result["stats"]:
|
||||||
|
result["stats"] = {stat_type: result["stats"][stat_type]}
|
||||||
|
return result
|
||||||
|
|
||||||
|
season = get_current_season()
|
||||||
|
gamelog = _call_nba_api(
|
||||||
|
playergamelog.PlayerGameLog,
|
||||||
|
player_id=player_id,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
df = gamelog.get_data_frames()[0]
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return {
|
||||||
|
"player": full_name,
|
||||||
|
"player_id": player_id,
|
||||||
|
"team": "UNK",
|
||||||
|
"last_n": n,
|
||||||
|
"source": "live",
|
||||||
|
"stats": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
last_n_df = df.head(n)
|
||||||
|
team = last_n_df.iloc[0].get("TEAM_ABBREVIATION", "UNK") if not last_n_df.empty else "UNK"
|
||||||
|
|
||||||
|
# Compute averages
|
||||||
|
avg_row = {}
|
||||||
|
for col in STAT_MAP:
|
||||||
|
if col in last_n_df.columns:
|
||||||
|
avg_row[col] = last_n_df[col].mean()
|
||||||
|
avg_row["GP"] = len(last_n_df)
|
||||||
|
|
||||||
|
stats = _map_stats(avg_row)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"player": full_name,
|
||||||
|
"player_id": player_id,
|
||||||
|
"team": team,
|
||||||
|
"last_n": n,
|
||||||
|
"source": "live",
|
||||||
|
"stats": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_set(cache_key, result, LAST_N_TTL)
|
||||||
|
|
||||||
|
if stat_type and stat_type in result["stats"]:
|
||||||
|
result_filtered = dict(result)
|
||||||
|
result_filtered["stats"] = {stat_type: result["stats"][stat_type]}
|
||||||
|
return result_filtered
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_splits(player_name, stat_type, split_type, opponent=None):
|
||||||
|
"""Get situational splits for a player."""
|
||||||
|
player_id, full_name = resolve_player(player_name)
|
||||||
|
if player_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = f"nba:splits:{player_id}:{stat_type}:{split_type}"
|
||||||
|
if opponent:
|
||||||
|
cache_key += f":{opponent}"
|
||||||
|
|
||||||
|
cached = cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
cached["source"] = "cache"
|
||||||
|
return cached
|
||||||
|
|
||||||
|
season = get_current_season()
|
||||||
|
gamelog = _call_nba_api(
|
||||||
|
playergamelog.PlayerGameLog,
|
||||||
|
player_id=player_id,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
df = gamelog.get_data_frames()[0]
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return {
|
||||||
|
"player": full_name,
|
||||||
|
"player_id": player_id,
|
||||||
|
"stat_type": stat_type,
|
||||||
|
"split_type": split_type,
|
||||||
|
"source": "live",
|
||||||
|
"splits": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map stat_type to nba_api column
|
||||||
|
reverse_map = {v: k for k, v in STAT_MAP.items()}
|
||||||
|
if stat_type == "pra":
|
||||||
|
nba_cols = ["PTS", "REB", "AST"]
|
||||||
|
else:
|
||||||
|
nba_col = reverse_map.get(stat_type)
|
||||||
|
if nba_col is None or nba_col not in df.columns:
|
||||||
|
return None
|
||||||
|
nba_cols = [nba_col]
|
||||||
|
|
||||||
|
def avg_stat(subset):
|
||||||
|
if subset.empty:
|
||||||
|
return 0
|
||||||
|
if stat_type == "pra":
|
||||||
|
return round((subset["PTS"] + subset["REB"] + subset["AST"]).mean(), 1)
|
||||||
|
return round(subset[nba_cols[0]].mean(), 1)
|
||||||
|
|
||||||
|
team = df.iloc[0].get("TEAM_ABBREVIATION", "UNK") if not df.empty else "UNK"
|
||||||
|
|
||||||
|
if split_type == "home_away":
|
||||||
|
# MATCHUP contains "vs." for home games, "@" for away
|
||||||
|
home = df[df["MATCHUP"].str.contains("vs.", na=False)]
|
||||||
|
away = df[df["MATCHUP"].str.contains("@", na=False)]
|
||||||
|
splits = {
|
||||||
|
"home": {"avg": avg_stat(home), "games": len(home)},
|
||||||
|
"away": {"avg": avg_stat(away), "games": len(away)},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif split_type == "rest_days":
|
||||||
|
df = df.copy()
|
||||||
|
df["GAME_DATE_PARSED"] = df["GAME_DATE"].apply(_parse_game_date)
|
||||||
|
df = df.sort_values("GAME_DATE_PARSED")
|
||||||
|
|
||||||
|
b2b = []
|
||||||
|
one_day = []
|
||||||
|
two_plus = []
|
||||||
|
|
||||||
|
dates = df["GAME_DATE_PARSED"].tolist()
|
||||||
|
for i, row_idx in enumerate(df.index):
|
||||||
|
if i == 0:
|
||||||
|
two_plus.append(row_idx)
|
||||||
|
continue
|
||||||
|
delta = (dates[i] - dates[i - 1]).days
|
||||||
|
if delta <= 1:
|
||||||
|
b2b.append(row_idx)
|
||||||
|
elif delta == 2:
|
||||||
|
one_day.append(row_idx)
|
||||||
|
else:
|
||||||
|
two_plus.append(row_idx)
|
||||||
|
|
||||||
|
splits = {
|
||||||
|
"b2b": {"avg": avg_stat(df.loc[b2b]) if b2b else 0, "games": len(b2b)},
|
||||||
|
"1_day_rest": {"avg": avg_stat(df.loc[one_day]) if one_day else 0, "games": len(one_day)},
|
||||||
|
"2_plus_days_rest": {"avg": avg_stat(df.loc[two_plus]) if two_plus else 0, "games": len(two_plus)},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif split_type == "vs_team":
|
||||||
|
if not opponent:
|
||||||
|
return None
|
||||||
|
opponent_upper = opponent.upper()
|
||||||
|
vs_opp = df[df["MATCHUP"].str.contains(opponent_upper, na=False)]
|
||||||
|
vs_others = df[~df["MATCHUP"].str.contains(opponent_upper, na=False)]
|
||||||
|
splits = {
|
||||||
|
"vs_opponent": {"avg": avg_stat(vs_opp), "games": len(vs_opp)},
|
||||||
|
"vs_all_others": {"avg": avg_stat(vs_others), "games": len(vs_others)},
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"player": full_name,
|
||||||
|
"player_id": player_id,
|
||||||
|
"team": team,
|
||||||
|
"stat_type": stat_type,
|
||||||
|
"split_type": split_type,
|
||||||
|
"source": "live",
|
||||||
|
"splits": splits,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opponent:
|
||||||
|
result["opponent"] = opponent
|
||||||
|
|
||||||
|
cache_set(cache_key, result, SPLITS_TTL)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_game_date(date_str):
|
||||||
|
"""Parse game date from nba_api format. Handles 'MAR 21, 2026' and similar."""
|
||||||
|
for fmt in ("%b %d, %Y", "%Y-%m-%d", "%m/%d/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, fmt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import json
|
||||||
|
import redis as redis_lib
|
||||||
|
from app.config import REDIS_URL
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis():
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = redis_lib.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get(key):
|
||||||
|
r = get_redis()
|
||||||
|
data = r.get(key)
|
||||||
|
if data is not None:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(key, value, ttl):
|
||||||
|
r = get_redis()
|
||||||
|
r.set(key, json.dumps(value), ex=ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_health():
|
||||||
|
try:
|
||||||
|
r = get_redis()
|
||||||
|
r.ping()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from nba_api.stats.static import players
|
||||||
|
from app.utils.cache import cache_get, cache_set
|
||||||
|
from app.config import PLAYER_SEARCH_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(name):
|
||||||
|
return name.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def search_players(name):
|
||||||
|
cache_key = f"nba:player:{normalize_name(name)}"
|
||||||
|
cached = cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
all_players = players.get_players()
|
||||||
|
search_lower = normalize_name(name)
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for p in all_players:
|
||||||
|
full_name = p["full_name"].lower()
|
||||||
|
if search_lower in full_name:
|
||||||
|
matches.append({
|
||||||
|
"player_id": p["id"],
|
||||||
|
"full_name": p["full_name"],
|
||||||
|
"is_active": p["is_active"],
|
||||||
|
})
|
||||||
|
|
||||||
|
cache_set(cache_key, matches, PLAYER_SEARCH_TTL)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_player(name):
|
||||||
|
"""Resolve a player name to a single active player. Returns (player_id, full_name) or raises."""
|
||||||
|
matches = search_players(name)
|
||||||
|
active = [m for m in matches if m["is_active"]]
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
if matches:
|
||||||
|
# Return first inactive match as fallback
|
||||||
|
return matches[0]["player_id"], matches[0]["full_name"]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Prefer exact match
|
||||||
|
search_lower = normalize_name(name)
|
||||||
|
for m in active:
|
||||||
|
if m["full_name"].lower() == search_lower:
|
||||||
|
return m["player_id"], m["full_name"]
|
||||||
|
|
||||||
|
return active[0]["player_id"], active[0]["full_name"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn==0.34.2
|
||||||
|
nba_api==1.11.4
|
||||||
|
redis==5.3.0
|
||||||
|
httpx==0.28.1
|
||||||
|
pytest==8.3.5
|
||||||
|
pytest-asyncio==0.25.3
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
MOCK_PLAYERS = [{"id": 203999, "full_name": "Nikola Jokic", "is_active": True}]
|
||||||
|
|
||||||
|
MOCK_CAREER_DF = pd.DataFrame([{
|
||||||
|
"SEASON_ID": "2025-26",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 26.3, "REB": 12.4, "AST": 9.1, "FG3M": 1.1,
|
||||||
|
"BLK": 0.7, "STL": 1.4, "TOV": 3.2, "MIN": 34.2, "GP": 65,
|
||||||
|
}])
|
||||||
|
|
||||||
|
MOCK_GAMELOG_DF = pd.DataFrame([
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 21, 2026", "MATCHUP": "DEN vs. LAL",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 30, "REB": 15, "AST": 10, "FG3M": 2, "BLK": 1, "STL": 2, "TOV": 3, "MIN": 36,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 20, 2026", "MATCHUP": "DEN @ PHX",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 22, "REB": 10, "AST": 8, "FG3M": 0, "BLK": 0, "STL": 1, "TOV": 4, "MIN": 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 18, 2026", "MATCHUP": "DEN vs. LAL",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 28, "REB": 12, "AST": 9, "FG3M": 1, "BLK": 1, "STL": 1, "TOV": 2, "MIN": 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 16, 2026", "MATCHUP": "DEN @ GSW",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 24, "REB": 11, "AST": 7, "FG3M": 1, "BLK": 0, "STL": 2, "TOV": 3, "MIN": 33,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 14, 2026", "MATCHUP": "DEN vs. MIA",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 26, "REB": 13, "AST": 11, "FG3M": 2, "BLK": 1, "STL": 1, "TOV": 2, "MIN": 37,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_career(*args, **kwargs):
|
||||||
|
m = MagicMock()
|
||||||
|
m.get_data_frames.return_value = [MOCK_CAREER_DF]
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_gamelog(*args, **kwargs):
|
||||||
|
m = MagicMock()
|
||||||
|
m.get_data_frames.return_value = [MOCK_GAMELOG_DF]
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_nba_api():
|
||||||
|
"""Mocks all external dependencies: nba_api, Redis cache, rate limiter."""
|
||||||
|
with patch("app.services.stats.playercareerstats.PlayerCareerStats", side_effect=_mock_career), \
|
||||||
|
patch("app.services.stats.playergamelog.PlayerGameLog", side_effect=_mock_gamelog), \
|
||||||
|
patch("app.utils.player_map.players.get_players", return_value=MOCK_PLAYERS), \
|
||||||
|
patch("app.services.stats.cache_get", return_value=None), \
|
||||||
|
patch("app.services.stats.cache_set"), \
|
||||||
|
patch("app.utils.player_map.cache_get", return_value=None), \
|
||||||
|
patch("app.utils.player_map.cache_set"), \
|
||||||
|
patch("app.services.stats.time.sleep"):
|
||||||
|
yield
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
MOCK_CAREER_DF = pd.DataFrame([{
|
||||||
|
"SEASON_ID": "2025-26",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 26.3, "REB": 12.4, "AST": 9.1, "FG3M": 1.1,
|
||||||
|
"BLK": 0.7, "STL": 1.4, "TOV": 3.2, "MIN": 34.2, "GP": 65,
|
||||||
|
}])
|
||||||
|
|
||||||
|
MOCK_GAMELOG_DF = pd.DataFrame([
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 21, 2026", "MATCHUP": "DEN vs. LAL",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 30, "REB": 15, "AST": 10, "FG3M": 2, "BLK": 1, "STL": 2, "TOV": 3, "MIN": 36,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GAME_DATE": "MAR 20, 2026", "MATCHUP": "DEN @ PHX",
|
||||||
|
"TEAM_ABBREVIATION": "DEN",
|
||||||
|
"PTS": 22, "REB": 10, "AST": 8, "FG3M": 0, "BLK": 0, "STL": 1, "TOV": 4, "MIN": 32,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
MOCK_PLAYERS = [{"id": 203999, "full_name": "Nikola Jokic", "is_active": True}]
|
||||||
|
|
||||||
|
|
||||||
|
def mock_career(*args, **kwargs):
|
||||||
|
m = MagicMock()
|
||||||
|
m.get_data_frames.return_value = [MOCK_CAREER_DF]
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def mock_gamelog(*args, **kwargs):
|
||||||
|
m = MagicMock()
|
||||||
|
m.get_data_frames.return_value = [MOCK_GAMELOG_DF]
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_nba_api():
|
||||||
|
with patch("app.services.stats.playercareerstats.PlayerCareerStats", side_effect=mock_career), \
|
||||||
|
patch("app.services.stats.playergamelog.PlayerGameLog", side_effect=mock_gamelog), \
|
||||||
|
patch("app.utils.player_map.players.get_players", return_value=MOCK_PLAYERS), \
|
||||||
|
patch("app.services.stats.cache_get", return_value=None), \
|
||||||
|
patch("app.services.stats.cache_set"), \
|
||||||
|
patch("app.utils.player_map.cache_get", return_value=None), \
|
||||||
|
patch("app.utils.player_map.cache_set"), \
|
||||||
|
patch("app.services.stats.time.sleep"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health():
|
||||||
|
with patch("app.main.cache_health", return_value=True):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_player_search(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/players/search?name=Jokic")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["results"]) > 0
|
||||||
|
assert resp.json()["results"][0]["full_name"] == "Nikola Jokic"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_player_search_not_found():
|
||||||
|
with patch("app.utils.player_map.players.get_players", return_value=[]), \
|
||||||
|
patch("app.utils.player_map.cache_get", return_value=None), \
|
||||||
|
patch("app.utils.player_map.cache_set"):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/players/search?name=Zzzzzzz")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_season_avg(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/season-avg?player=Nikola Jokic&season=2025-26")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["player"] == "Nikola Jokic"
|
||||||
|
assert data["team"] == "DEN"
|
||||||
|
assert "points" in data["stats"]
|
||||||
|
assert "pra" in data["stats"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_season_avg_invalid_stat_type(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/season-avg?player=Jokic&stat_type=invalid")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_last_n(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/last-n?player=Nikola Jokic&n=2")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["last_n"] == 2
|
||||||
|
assert "points" in data["stats"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_last_n_clamps_max(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/last-n?player=Nikola Jokic&n=50")
|
||||||
|
# FastAPI validates n <= 30, returns 422
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_splits_home_away(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/splits?player=Nikola Jokic&stat_type=points&split_type=home_away")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "home" in data["splits"]
|
||||||
|
assert "away" in data["splits"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_splits_vs_team_requires_opponent(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/splits?player=Jokic&stat_type=points&split_type=vs_team")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "opponent" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_splits_invalid_split_type(mock_nba_api):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/splits?player=Jokic&stat_type=points&split_type=invalid")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_season_avg_player_not_found():
|
||||||
|
with patch("app.utils.player_map.players.get_players", return_value=[]), \
|
||||||
|
patch("app.utils.player_map.cache_get", return_value=None), \
|
||||||
|
patch("app.utils.player_map.cache_set"), \
|
||||||
|
patch("app.services.stats.cache_get", return_value=None), \
|
||||||
|
patch("app.services.stats.time.sleep"):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/stats/season-avg?player=Nobody")
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from app.services.stats import (
|
||||||
|
_map_stats,
|
||||||
|
get_current_season,
|
||||||
|
get_season_avg,
|
||||||
|
get_last_n,
|
||||||
|
get_splits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapStats:
|
||||||
|
def test_maps_all_nba_api_columns(self):
|
||||||
|
row = {
|
||||||
|
"PTS": 26.3, "REB": 12.4, "AST": 9.1, "FG3M": 1.1,
|
||||||
|
"BLK": 0.7, "STL": 1.4, "TOV": 3.2, "MIN": 34.2, "GP": 65,
|
||||||
|
}
|
||||||
|
result = _map_stats(row)
|
||||||
|
assert result["points"] == 26.3
|
||||||
|
assert result["rebounds"] == 12.4
|
||||||
|
assert result["assists"] == 9.1
|
||||||
|
assert result["threes"] == 1.1
|
||||||
|
assert result["blocks"] == 0.7
|
||||||
|
assert result["steals"] == 1.4
|
||||||
|
assert result["turnovers"] == 3.2
|
||||||
|
assert result["minutes"] == 34.2
|
||||||
|
assert result["games_played"] == 65.0
|
||||||
|
|
||||||
|
def test_computes_pra(self):
|
||||||
|
row = {"PTS": 25.0, "REB": 10.0, "AST": 8.0}
|
||||||
|
result = _map_stats(row)
|
||||||
|
assert result["pra"] == 43.0
|
||||||
|
|
||||||
|
def test_handles_missing_stats(self):
|
||||||
|
row = {}
|
||||||
|
result = _map_stats(row)
|
||||||
|
assert result["pra"] == 0
|
||||||
|
assert "points" not in result
|
||||||
|
|
||||||
|
def test_rounds_to_one_decimal(self):
|
||||||
|
row = {"PTS": 26.333333}
|
||||||
|
result = _map_stats(row)
|
||||||
|
assert result["points"] == 26.3
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentSeason:
|
||||||
|
def test_current_season_format(self):
|
||||||
|
season = get_current_season()
|
||||||
|
assert len(season) == 7
|
||||||
|
assert "-" in season
|
||||||
|
year = int(season[:4])
|
||||||
|
suffix = int(season[5:])
|
||||||
|
assert suffix == (year + 1) % 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSeasonAvg:
|
||||||
|
def test_returns_season_averages(self, mock_nba_api):
|
||||||
|
result = get_season_avg("Nikola Jokic", season="2025-26")
|
||||||
|
assert result is not None
|
||||||
|
assert result["player"] == "Nikola Jokic"
|
||||||
|
assert result["team"] == "DEN"
|
||||||
|
assert result["stats"]["points"] == 26.3
|
||||||
|
assert result["stats"]["pra"] == 47.8
|
||||||
|
assert result["source"] == "live"
|
||||||
|
|
||||||
|
def test_filters_by_stat_type(self, mock_nba_api):
|
||||||
|
result = get_season_avg("Nikola Jokic", stat_type="points", season="2025-26")
|
||||||
|
assert list(result["stats"].keys()) == ["points"]
|
||||||
|
|
||||||
|
def test_returns_cache_when_available(self):
|
||||||
|
cached = {
|
||||||
|
"player": "Nikola Jokic", "player_id": 203999, "team": "DEN",
|
||||||
|
"season": "2025-26", "source": "live", "stats": {"points": 26.3},
|
||||||
|
}
|
||||||
|
with patch("app.services.stats.cache_get", return_value=cached), \
|
||||||
|
patch("app.utils.player_map.cache_get", return_value=None), \
|
||||||
|
patch("app.utils.player_map.cache_set"), \
|
||||||
|
patch("app.utils.player_map.players.get_players", return_value=[
|
||||||
|
{"id": 203999, "full_name": "Nikola Jokic", "is_active": True}
|
||||||
|
]), \
|
||||||
|
patch("app.services.stats.time.sleep"):
|
||||||
|
result = get_season_avg("Nikola Jokic", season="2025-26")
|
||||||
|
assert result["source"] == "cache"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLastN:
|
||||||
|
def test_returns_last_n_averages(self, mock_nba_api):
|
||||||
|
result = get_last_n("Nikola Jokic", n=5)
|
||||||
|
assert result is not None
|
||||||
|
assert result["last_n"] == 5
|
||||||
|
assert result["stats"]["games_played"] == 5.0
|
||||||
|
assert result["source"] == "live"
|
||||||
|
assert result["stats"]["points"] == 26.0
|
||||||
|
|
||||||
|
def test_clamps_n_to_max_30(self, mock_nba_api):
|
||||||
|
result = get_last_n("Nikola Jokic", n=50)
|
||||||
|
assert result["last_n"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSplits:
|
||||||
|
def test_home_away_split(self, mock_nba_api):
|
||||||
|
result = get_splits("Nikola Jokic", "points", "home_away")
|
||||||
|
assert result is not None
|
||||||
|
assert "home" in result["splits"]
|
||||||
|
assert "away" in result["splits"]
|
||||||
|
assert result["splits"]["home"]["games"] == 3
|
||||||
|
assert result["splits"]["away"]["games"] == 2
|
||||||
|
|
||||||
|
def test_rest_days_split(self, mock_nba_api):
|
||||||
|
result = get_splits("Nikola Jokic", "points", "rest_days")
|
||||||
|
assert result is not None
|
||||||
|
assert "b2b" in result["splits"]
|
||||||
|
assert "1_day_rest" in result["splits"]
|
||||||
|
assert "2_plus_days_rest" in result["splits"]
|
||||||
|
|
||||||
|
def test_vs_team_split(self, mock_nba_api):
|
||||||
|
result = get_splits("Nikola Jokic", "points", "vs_team", opponent="LAL")
|
||||||
|
assert result is not None
|
||||||
|
assert "vs_opponent" in result["splits"]
|
||||||
|
assert "vs_all_others" in result["splits"]
|
||||||
|
assert result["splits"]["vs_opponent"]["games"] == 2
|
||||||
|
|
||||||
|
def test_vs_team_requires_opponent(self, mock_nba_api):
|
||||||
|
result = get_splits("Nikola Jokic", "points", "vs_team", opponent=None)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlayerNotFound:
|
||||||
|
@patch("app.utils.player_map.players.get_players", return_value=[])
|
||||||
|
@patch("app.utils.player_map.cache_get", return_value=None)
|
||||||
|
@patch("app.utils.player_map.cache_set")
|
||||||
|
def test_season_avg_returns_none(self, mock_set, mock_get, mock_players):
|
||||||
|
result = get_season_avg("Nonexistent Player")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("app.utils.player_map.players.get_players", return_value=[])
|
||||||
|
@patch("app.utils.player_map.cache_get", return_value=None)
|
||||||
|
@patch("app.utils.player_map.cache_set")
|
||||||
|
def test_last_n_returns_none(self, mock_set, mock_get, mock_players):
|
||||||
|
result = get_last_n("Nonexistent Player")
|
||||||
|
assert result is None
|
||||||
Generated
+142
-6
@@ -9,10 +9,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ioredis": "^5.10.1"
|
"ioredis": "^5.10.1",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^30.3.0",
|
"jest": "^30.3.0",
|
||||||
@@ -1077,6 +1079,86 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@sinonjs/commons": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-vMEVLA1kGGYd/kdsJSwtjiFUZM1nGfrz2DWmgMBZtocV48qL+L2+4QpIkueXyBEumMQZFEyhz57i/5zGHjvdBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-6tk2zrcBkzKaaBXPOG5nshn30uJNFGOH9LxOnE8i850eQmsX+jVm7vql9kTPyvUzEHwU4zdjSOkXS9M+9ukMVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-8HxEf+zNycj7Z8+ONhhlu+7J7Ha+L6weyCtdEeK2mN5OWJbh6n4LPU4iuJ5UlCvvNnbSXMoutY7piITEEAgl2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-c1azgZ2nZPczbY5k5u5iFrk1InpxN81IvNE+UBAkjrBz3yc5ALLJNkeTQwbJZT4PZBuYXEzqYGLMuh9fdTtTMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-lOfIm4hInNcd8x0i1LWphnLKxec42wwbjs+vhaVAvR801Vda0UAMbTooUY6gfqgQb8v29GofqKuQMMTAsl6w/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-GuPbzoEaI51AkLw9VGhLNvnzw4PHbS3p8j2/JlvLeZNQMKwZw4aEYQIDBRtFwL5Nv7/275n9m4DHtakY8nCvgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.99.3",
|
||||||
|
"@supabase/functions-js": "2.99.3",
|
||||||
|
"@supabase/postgrest-js": "2.99.3",
|
||||||
|
"@supabase/realtime-js": "2.99.3",
|
||||||
|
"@supabase/storage-js": "2.99.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1164,12 +1246,17 @@
|
|||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/stack-utils": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@@ -1177,6 +1264,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -2923,6 +3019,15 @@
|
|||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -4352,6 +4457,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres": {
|
||||||
|
"version": "3.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz",
|
||||||
|
"integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/porsager"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "30.3.0",
|
"version": "30.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
|
||||||
@@ -5093,9 +5211,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-detect": {
|
"node_modules/type-detect": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
@@ -5138,7 +5254,6 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
@@ -5381,6 +5496,27 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
+3
-1
@@ -25,10 +25,12 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/kev3109/betonblk#readme",
|
"homepage": "https://github.com/kev3109/betonblk#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ioredis": "^5.10.1"
|
"ioredis": "^5.10.1",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^30.3.0",
|
"jest": "^30.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const postgres = require('postgres');
|
||||||
|
|
||||||
|
const DB_PASSWORD = process.env.SUPABASE_DB_PASSWORD;
|
||||||
|
const PROJECT_REF = process.env.SUPABASE_URL.match(/https:\/\/(.+?)\.supabase/)[1];
|
||||||
|
|
||||||
|
// Use session mode (port 5432) for DDL statements
|
||||||
|
const sql = postgres({
|
||||||
|
host: `aws-0-us-east-1.pooler.supabase.com`,
|
||||||
|
port: 5432,
|
||||||
|
database: 'postgres',
|
||||||
|
username: `postgres.${PROJECT_REF}`,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
ssl: { rejectUnauthorized: false },
|
||||||
|
connection: { application_name: 'betonblk-migration' },
|
||||||
|
prepare: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function applyMigration() {
|
||||||
|
// Test connection first
|
||||||
|
try {
|
||||||
|
const result = await sql`SELECT current_database(), current_user`;
|
||||||
|
console.log('Connected:', result[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Connection failed:', err.message);
|
||||||
|
|
||||||
|
// Try transaction mode as fallback
|
||||||
|
console.log('Trying transaction mode on port 6543...');
|
||||||
|
const sql2 = postgres({
|
||||||
|
host: `aws-0-us-east-1.pooler.supabase.com`,
|
||||||
|
port: 6543,
|
||||||
|
database: 'postgres',
|
||||||
|
username: `postgres.${PROJECT_REF}`,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
ssl: { rejectUnauthorized: false },
|
||||||
|
prepare: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result2 = await sql2`SELECT current_database(), current_user`;
|
||||||
|
console.log('Connected via transaction mode:', result2[0]);
|
||||||
|
await runMigration(sql2);
|
||||||
|
await sql2.end();
|
||||||
|
return;
|
||||||
|
} catch (err2) {
|
||||||
|
console.error('Transaction mode also failed:', err2.message);
|
||||||
|
|
||||||
|
// Try direct connection
|
||||||
|
console.log('Trying direct connection...');
|
||||||
|
const sql3 = postgres({
|
||||||
|
host: `db.${PROJECT_REF}.supabase.co`,
|
||||||
|
port: 5432,
|
||||||
|
database: 'postgres',
|
||||||
|
username: 'postgres',
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
ssl: { rejectUnauthorized: false },
|
||||||
|
prepare: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result3 = await sql3`SELECT current_database(), current_user`;
|
||||||
|
console.log('Connected directly:', result3[0]);
|
||||||
|
await runMigration(sql3);
|
||||||
|
await sql3.end();
|
||||||
|
return;
|
||||||
|
} catch (err3) {
|
||||||
|
console.error('Direct connection failed:', err3.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runMigration(sql);
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigration(db) {
|
||||||
|
const migration = fs.readFileSync('supabase/migrations/001_initial_schema.sql', 'utf8');
|
||||||
|
const statements = splitStatements(migration);
|
||||||
|
|
||||||
|
console.log(`Applying ${statements.length} statements...`);
|
||||||
|
let ok = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < statements.length; i++) {
|
||||||
|
const stmt = statements[i].trim();
|
||||||
|
if (!stmt || stmt.startsWith('--')) continue;
|
||||||
|
try {
|
||||||
|
await db.unsafe(stmt);
|
||||||
|
ok++;
|
||||||
|
console.log(`[${i + 1}/${statements.length}] OK`);
|
||||||
|
} catch (err) {
|
||||||
|
errors++;
|
||||||
|
console.error(`[${i + 1}/${statements.length}] ERROR: ${err.message}`);
|
||||||
|
console.error(`Statement: ${stmt.substring(0, 120)}...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete. ${ok} succeeded, ${errors} failed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitStatements(sqlText) {
|
||||||
|
const results = [];
|
||||||
|
let current = '';
|
||||||
|
let inDollarQuote = false;
|
||||||
|
|
||||||
|
const lines = sqlText.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('--')) {
|
||||||
|
current += line + '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dollarCount = (line.match(/\$\$/g) || []).length;
|
||||||
|
if (dollarCount % 2 !== 0) {
|
||||||
|
inDollarQuote = !inDollarQuote;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += line + '\n';
|
||||||
|
|
||||||
|
if (!inDollarQuote && trimmed.endsWith(';')) {
|
||||||
|
results.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim()) {
|
||||||
|
results.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMigration().catch(console.error);
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start both BetonBLK services
|
||||||
|
# Usage: ./scripts/start.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[BetonBLK] Starting services..."
|
||||||
|
|
||||||
|
# Start Python NBA stats service (port 8000)
|
||||||
|
echo "[BetonBLK] Starting NBA stats service on port 8000..."
|
||||||
|
cd nba-service
|
||||||
|
source venv/bin/activate
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 &
|
||||||
|
NBA_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Start Node.js API server (port 3000)
|
||||||
|
echo "[BetonBLK] Starting Node API server on port 3000..."
|
||||||
|
node src/server.js &
|
||||||
|
NODE_PID=$!
|
||||||
|
|
||||||
|
echo "[BetonBLK] Services running:"
|
||||||
|
echo " Node API: http://localhost:3000 (PID: $NODE_PID)"
|
||||||
|
echo " NBA Stats: http://localhost:8000 (PID: $NBA_PID)"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop all services."
|
||||||
|
|
||||||
|
trap "kill $NBA_PID $NODE_PID 2>/dev/null; echo '[BetonBLK] Services stopped.'" EXIT
|
||||||
|
|
||||||
|
wait
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SERVICE_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const EXPECTED_TABLES = ['users', 'picks', 'scan_sessions', 'bets', 'outcomes', 'performance'];
|
||||||
|
|
||||||
|
async function verifySchema() {
|
||||||
|
console.log('[BetonBLK] Verifying database schema...\n');
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
// Check each table exists by querying it
|
||||||
|
for (const table of EXPECTED_TABLES) {
|
||||||
|
const { data, error } = await supabase.from(table).select('*').limit(0);
|
||||||
|
if (error) {
|
||||||
|
console.error(` FAIL: ${table} — ${error.message}`);
|
||||||
|
allPassed = false;
|
||||||
|
} else {
|
||||||
|
console.log(` OK: ${table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test RLS: service role should be able to query all tables
|
||||||
|
console.log('Testing service role access...');
|
||||||
|
for (const table of EXPECTED_TABLES) {
|
||||||
|
const { error } = await supabase.from(table).select('*').limit(1);
|
||||||
|
if (error) {
|
||||||
|
console.error(` FAIL: service role cannot access ${table} — ${error.message}`);
|
||||||
|
allPassed = false;
|
||||||
|
} else {
|
||||||
|
console.log(` OK: service role can access ${table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (allPassed) {
|
||||||
|
console.log('ALL CHECKS PASSED. Schema is correctly applied.');
|
||||||
|
} else {
|
||||||
|
console.error('SOME CHECKS FAILED. Review errors above.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifySchema().catch((err) => {
|
||||||
|
console.error('Verification failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Feature 1.2 — NBA_API Stats Wrapper (FastAPI Microservice)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A Python FastAPI microservice wrapping the `nba_api` library. Provides player stats (season averages, last N games, situational splits) via internal HTTP endpoints. Caches aggressively in Redis. Called by the Node.js backend — not exposed to the public internet.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- None (builds parallel with Feature 1.1)
|
||||||
|
- Downstream consumers: Feature 1.3 (Prop Analysis Engine) needs season averages + recent game data to grade props
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
- **Language:** Python 3.11+
|
||||||
|
- **Framework:** FastAPI + uvicorn
|
||||||
|
- **Data source:** `nba_api` (free, no key required)
|
||||||
|
- **Cache:** Redis (shared instance with Node backend)
|
||||||
|
- **Port:** 8000 (internal only)
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
```
|
||||||
|
nba-service/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI app, routes
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── stats.py # nba_api wrapper functions
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── cache.py # Redis caching helpers
|
||||||
|
│ │ └── player_map.py # Player name -> nba_api player_id resolution
|
||||||
|
│ └── config.py # Settings (Redis URL, cache TTLs)
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_stats.py # Unit tests for stats service
|
||||||
|
│ └── test_routes.py # Integration tests for endpoints
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /stats/season-avg
|
||||||
|
Returns a player's season averages for the current season.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|--------------------------------------------|
|
||||||
|
| player | string | yes | Player full name (e.g., "Nikola Jokic") |
|
||||||
|
| stat_type | string | no | Filter to specific stat (default: all) |
|
||||||
|
| season | string | no | NBA season (default: current, e.g., "2025-26") |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": "Nikola Jokic",
|
||||||
|
"player_id": 203999,
|
||||||
|
"team": "DEN",
|
||||||
|
"season": "2025-26",
|
||||||
|
"source": "cache | live",
|
||||||
|
"stats": {
|
||||||
|
"points": 26.3,
|
||||||
|
"rebounds": 12.4,
|
||||||
|
"assists": 9.1,
|
||||||
|
"threes": 1.1,
|
||||||
|
"blocks": 0.7,
|
||||||
|
"steals": 1.4,
|
||||||
|
"pra": 47.8,
|
||||||
|
"turnovers": 3.2,
|
||||||
|
"games_played": 65,
|
||||||
|
"minutes": 34.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /stats/last-n
|
||||||
|
Returns a player's averages over their last N games.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-----------|--------|----------|---------|------------------------------------|
|
||||||
|
| player | string | yes | | Player full name |
|
||||||
|
| n | int | no | 10 | Number of recent games (max: 30) |
|
||||||
|
| stat_type | string | no | all | Filter to specific stat |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": "Nikola Jokic",
|
||||||
|
"player_id": 203999,
|
||||||
|
"team": "DEN",
|
||||||
|
"last_n": 10,
|
||||||
|
"source": "cache | live",
|
||||||
|
"stats": {
|
||||||
|
"points": 28.1,
|
||||||
|
"rebounds": 13.0,
|
||||||
|
"assists": 10.2,
|
||||||
|
"threes": 1.3,
|
||||||
|
"blocks": 0.8,
|
||||||
|
"steals": 1.5,
|
||||||
|
"pra": 51.3,
|
||||||
|
"turnovers": 2.9,
|
||||||
|
"games_played": 10,
|
||||||
|
"minutes": 35.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /stats/splits
|
||||||
|
Returns situational splits for a player.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|------------|--------|----------|---------|------------------------------------------|
|
||||||
|
| player | string | yes | | Player full name |
|
||||||
|
| stat_type | string | yes | | Stat to split (points, rebounds, etc.) |
|
||||||
|
| split_type | string | yes | | One of: home_away, rest_days, vs_team |
|
||||||
|
| opponent | string | no | | Required when split_type=vs_team (e.g., "LAL") |
|
||||||
|
|
||||||
|
**Response (200) — home_away split:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": "Nikola Jokic",
|
||||||
|
"player_id": 203999,
|
||||||
|
"team": "DEN",
|
||||||
|
"stat_type": "points",
|
||||||
|
"split_type": "home_away",
|
||||||
|
"source": "cache | live",
|
||||||
|
"splits": {
|
||||||
|
"home": { "avg": 27.8, "games": 33 },
|
||||||
|
"away": { "avg": 24.9, "games": 32 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200) — rest_days split (back-to-back detection):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": "Nikola Jokic",
|
||||||
|
"stat_type": "points",
|
||||||
|
"split_type": "rest_days",
|
||||||
|
"source": "cache | live",
|
||||||
|
"splits": {
|
||||||
|
"b2b": { "avg": 23.1, "games": 8 },
|
||||||
|
"1_day_rest": { "avg": 26.5, "games": 40 },
|
||||||
|
"2_plus_days_rest": { "avg": 28.2, "games": 17 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200) — vs_team split:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"player": "Nikola Jokic",
|
||||||
|
"stat_type": "points",
|
||||||
|
"split_type": "vs_team",
|
||||||
|
"opponent": "LAL",
|
||||||
|
"source": "cache | live",
|
||||||
|
"splits": {
|
||||||
|
"vs_opponent": { "avg": 30.5, "games": 3 },
|
||||||
|
"vs_all_others": { "avg": 25.8, "games": 62 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /players/search
|
||||||
|
Resolves player name to nba_api player ID. Used internally.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|--------|--------|----------|----------------------|
|
||||||
|
| name | string | yes | Player name (partial match OK) |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{ "player_id": 203999, "full_name": "Nikola Jokic", "team": "DEN", "is_active": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
Health check for the microservice.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "cache": "connected" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
| Status | When | Body |
|
||||||
|
|--------|----------------------------------------|-----------------------------------------------|
|
||||||
|
| 400 | Missing required param or invalid value | `{ "error": "player is required" }` |
|
||||||
|
| 404 | Player not found in nba_api | `{ "error": "Player not found: Xyz" }` |
|
||||||
|
| 503 | nba_api unreachable or rate limited | `{ "error": "NBA stats service unavailable" }`|
|
||||||
|
|
||||||
|
## Data Shape (Internal)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PlayerStats:
|
||||||
|
player: str
|
||||||
|
player_id: int
|
||||||
|
team: str # 3-letter abbreviation
|
||||||
|
season: str # e.g., "2025-26"
|
||||||
|
stats: dict # { "points": 26.3, "rebounds": 12.4, ... }
|
||||||
|
games_played: int
|
||||||
|
minutes: float
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stat Mapping
|
||||||
|
Map nba_api column names to our internal stat names:
|
||||||
|
|
||||||
|
| nba_api Column | Internal stat_type |
|
||||||
|
|----------------|--------------------|
|
||||||
|
| PTS | points |
|
||||||
|
| REB | rebounds |
|
||||||
|
| AST | assists |
|
||||||
|
| FG3M | threes |
|
||||||
|
| BLK | blocks |
|
||||||
|
| STL | steals |
|
||||||
|
| TOV | turnovers |
|
||||||
|
| (PTS+REB+AST) | pra (computed) |
|
||||||
|
| MIN | minutes |
|
||||||
|
| GP | games_played |
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
- **Store:** Redis (same instance as Node backend)
|
||||||
|
- **Key patterns:**
|
||||||
|
- Season avg: `nba:season:{player_id}:{season}` — TTL: 24 hours
|
||||||
|
- Last N: `nba:last:{player_id}:{n}` — TTL: 1 hour
|
||||||
|
- Splits: `nba:splits:{player_id}:{stat_type}:{split_type}` — TTL: 6 hours
|
||||||
|
- Player search: `nba:player:{name_normalized}` — TTL: 7 days
|
||||||
|
- **On cache hit:** Return with `"source": "cache"`
|
||||||
|
- **On cache miss:** Fetch from nba_api, store, return with `"source": "live"`
|
||||||
|
- **On nba_api failure:** Return stale cache if available, 503 if not
|
||||||
|
|
||||||
|
## Player Name Resolution
|
||||||
|
- nba_api requires player_id for most endpoints
|
||||||
|
- Use `nba_api.stats.static.players.find_players_by_full_name()` for lookup
|
||||||
|
- Cache player_id mappings for 7 days (players don't change teams mid-game)
|
||||||
|
- Support partial matching: "Jokic" should resolve to "Nikola Jokic"
|
||||||
|
- If multiple matches, return all and let caller disambiguate
|
||||||
|
|
||||||
|
## nba_api Rate Limiting
|
||||||
|
- nba_api hits NBA.com endpoints which are rate-limited (no official docs)
|
||||||
|
- Add a 0.6s delay between nba_api calls to avoid getting blocked
|
||||||
|
- If a request fails with connection error, retry once after 2s
|
||||||
|
- If retry fails, serve from cache or return 503
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `GET /stats/season-avg?player=Nikola Jokic` returns correct season averages
|
||||||
|
2. `GET /stats/last-n?player=Nikola Jokic&n=10` returns last 10 game averages
|
||||||
|
3. `GET /stats/splits?player=Nikola Jokic&stat_type=points&split_type=home_away` returns home/away split
|
||||||
|
4. `GET /stats/splits?player=Nikola Jokic&stat_type=points&split_type=rest_days` returns B2B/rest splits
|
||||||
|
5. `GET /stats/splits?player=Nikola Jokic&stat_type=points&split_type=vs_team&opponent=LAL` returns vs-team split
|
||||||
|
6. `GET /players/search?name=Jokic` resolves to correct player with ID and team
|
||||||
|
7. Season averages are cached for 24 hours; subsequent calls return from cache
|
||||||
|
8. Last N averages are cached for 1 hour
|
||||||
|
9. Player search results are cached for 7 days
|
||||||
|
10. If nba_api is unreachable, stale cache is returned; if no cache, 503
|
||||||
|
11. All timestamps and dates use UTC
|
||||||
|
12. `GET /health` returns status and cache connectivity
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
### Unit Tests (stats.py)
|
||||||
|
- Maps nba_api raw stats to internal stat names correctly
|
||||||
|
- Computes PRA from PTS + REB + AST
|
||||||
|
- Handles missing stats gracefully (player with 0 games returns empty)
|
||||||
|
- Player search returns correct matches for full and partial names
|
||||||
|
- Back-to-back detection: identifies games on consecutive days
|
||||||
|
|
||||||
|
### Unit Tests (cache.py)
|
||||||
|
- Cache hit returns stored data
|
||||||
|
- Cache miss returns None
|
||||||
|
- TTLs are set correctly per data type
|
||||||
|
|
||||||
|
### Integration Tests (routes)
|
||||||
|
- Full request cycle: GET /stats/season-avg with mocked nba_api -> verify response shape
|
||||||
|
- GET /stats/last-n with n=5, n=10, n=30 -> correct game counts
|
||||||
|
- GET /stats/splits for each split_type -> correct response shapes
|
||||||
|
- Player search with partial name -> returns matches
|
||||||
|
- Error: invalid player name -> 404
|
||||||
|
- Error: missing required param -> 400
|
||||||
|
- Error: nba_api down with warm cache -> returns stale data
|
||||||
|
- Error: nba_api down with cold cache -> returns 503
|
||||||
|
- Health check returns ok when Redis is connected
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- **nba_api game log structure:** Need to verify exact column names for game logs (used in last-n and splits). Will confirm during implementation with a test call.
|
||||||
|
- **Current season string:** nba_api uses format "2025-26" — need to compute this dynamically based on current date (season starts in October).
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# Feature 1.4 — Database Schema (Supabase + RLS)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Complete PostgreSQL schema in Supabase for all BetonBLK data. Uses Supabase Auth for user identity. Row Level Security (RLS) on all tables ensures users can only access their own data. Service role key used by backend for admin operations.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- None (builds parallel with Features 1.1, 1.2)
|
||||||
|
- Downstream consumers: Feature 1.5 (Bet Submission), Feature 2.1 (Parlay Scan), Feature 3.4 (Stripe)
|
||||||
|
|
||||||
|
## Auth Model
|
||||||
|
- **Provider:** Supabase Auth
|
||||||
|
- **Identity:** `auth.users` table (managed by Supabase)
|
||||||
|
- **Extension:** Our `public.users` table references `auth.users.id` as FK
|
||||||
|
- **RLS:** Enabled on all tables. Policies use `auth.uid()` to scope access.
|
||||||
|
- **Backend access:** Service role key bypasses RLS for server-side operations
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### users
|
||||||
|
Extends Supabase Auth with app-specific profile data.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.users (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'analyst', 'desk')),
|
||||||
|
scan_count INT NOT NULL DEFAULT 0,
|
||||||
|
scan_reset_date TIMESTAMPTZ NOT NULL DEFAULT (date_trunc('month', now()) + interval '1 month'),
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
founder_status BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**RLS Policies:**
|
||||||
|
```sql
|
||||||
|
-- Users can read their own row
|
||||||
|
CREATE POLICY "users_select_own" ON public.users
|
||||||
|
FOR SELECT USING (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Users can update their own row (except tier, scan_count — backend only)
|
||||||
|
CREATE POLICY "users_update_own" ON public.users
|
||||||
|
FOR UPDATE USING (auth.uid() = id)
|
||||||
|
WITH CHECK (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Insert handled by trigger on auth.users creation (backend/service role)
|
||||||
|
CREATE POLICY "users_insert_service" ON public.users
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### picks
|
||||||
|
Individual prop analysis results from scans.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.picks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
player TEXT NOT NULL,
|
||||||
|
stat_type TEXT NOT NULL,
|
||||||
|
line NUMERIC(5,1) NOT NULL,
|
||||||
|
book TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK (direction IN ('over', 'under')),
|
||||||
|
grade TEXT NOT NULL CHECK (grade IN ('A', 'B', 'C', 'D')),
|
||||||
|
edge_pct NUMERIC(5,2),
|
||||||
|
reasoning TEXT,
|
||||||
|
kill_conditions TEXT[],
|
||||||
|
confidence NUMERIC(4,2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_picks_user_id ON public.picks(user_id);
|
||||||
|
CREATE INDEX idx_picks_created_at ON public.picks(created_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
**RLS Policies:**
|
||||||
|
```sql
|
||||||
|
CREATE POLICY "picks_select_own" ON public.picks
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "picks_insert_own" ON public.picks
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### scan_sessions
|
||||||
|
Groups picks into a single scan/parlay analysis session.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.scan_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
legs UUID[] NOT NULL DEFAULT '{}',
|
||||||
|
final_grade TEXT CHECK (final_grade IN ('A', 'B', 'C', 'D')),
|
||||||
|
kill_conditions TEXT[],
|
||||||
|
correlation_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_scan_sessions_user_id ON public.scan_sessions(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**RLS Policies:**
|
||||||
|
```sql
|
||||||
|
CREATE POLICY "scan_sessions_select_own" ON public.scan_sessions
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "scan_sessions_insert_own" ON public.scan_sessions
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### bets
|
||||||
|
User-submitted bets (via screenshot, quick slip, or sportsbook sync).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.bets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
amount NUMERIC(10,2) NOT NULL,
|
||||||
|
potential_payout NUMERIC(10,2),
|
||||||
|
slip_data JSONB NOT NULL,
|
||||||
|
book TEXT NOT NULL,
|
||||||
|
bet_type TEXT NOT NULL CHECK (bet_type IN ('straight', 'parlay', 'teaser', 'round_robin')),
|
||||||
|
submission_method TEXT NOT NULL CHECK (submission_method IN ('screenshot', 'quickslip', 'sync')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'won', 'lost', 'push', 'void')),
|
||||||
|
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_bets_user_id ON public.bets(user_id);
|
||||||
|
CREATE INDEX idx_bets_status ON public.bets(status);
|
||||||
|
CREATE INDEX idx_bets_placed_at ON public.bets(placed_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
**slip_data JSONB shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"player": "Nikola Jokic",
|
||||||
|
"stat_type": "points",
|
||||||
|
"line": 26.5,
|
||||||
|
"direction": "over",
|
||||||
|
"odds": -110
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_odds": -110,
|
||||||
|
"raw_text": "Jokic PRA 50.5 over $20 DraftKings"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**RLS Policies:**
|
||||||
|
```sql
|
||||||
|
CREATE POLICY "bets_select_own" ON public.bets
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bets_insert_own" ON public.bets
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bets_update_own" ON public.bets
|
||||||
|
FOR UPDATE USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### outcomes
|
||||||
|
Tracks actual results for each pick after the game is played.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.outcomes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pick_id UUID NOT NULL REFERENCES public.picks(id) ON DELETE CASCADE,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('hit', 'miss', 'push')),
|
||||||
|
actual_value NUMERIC(5,1) NOT NULL,
|
||||||
|
logged_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_outcomes_pick_id ON public.outcomes(pick_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**RLS Policies:**
|
||||||
|
```sql
|
||||||
|
-- Users can see outcomes for their own picks
|
||||||
|
CREATE POLICY "outcomes_select_own" ON public.outcomes
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.picks WHERE picks.id = outcomes.pick_id AND picks.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert by service role only (backend resolves outcomes)
|
||||||
|
-- No INSERT policy for anon/authenticated — backend uses service role
|
||||||
|
```
|
||||||
|
|
||||||
|
### performance
|
||||||
|
Aggregated performance metrics per user per time period.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.performance (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly', 'all_time')),
|
||||||
|
roi NUMERIC(6,2),
|
||||||
|
win_rate NUMERIC(5,2),
|
||||||
|
sample_size INT NOT NULL DEFAULT 0,
|
||||||
|
total_wagered NUMERIC(10,2) DEFAULT 0,
|
||||||
|
total_profit NUMERIC(10,2) DEFAULT 0,
|
||||||
|
calculated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_performance_user_id ON public.performance(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_performance_user_period ON public.performance(user_id, period);
|
||||||
|
```
|
||||||
|
|
||||||
|
**RLS Policies:**
|
||||||
|
```sql
|
||||||
|
CREATE POLICY "performance_select_own" ON public.performance
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Insert/update by service role only (backend calculates performance)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
### Auto-create user profile on signup
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.users (id, email)
|
||||||
|
VALUES (NEW.id, NEW.email);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-update updated_at on users table
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_updated_at
|
||||||
|
BEFORE UPDATE ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monthly scan count reset
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION public.reset_scan_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.scan_reset_date <= now() THEN
|
||||||
|
NEW.scan_count = 0;
|
||||||
|
NEW.scan_reset_date = date_trunc('month', now()) + interval '1 month';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_scan_reset
|
||||||
|
BEFORE UPDATE ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.reset_scan_count();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration File Structure
|
||||||
|
```
|
||||||
|
supabase/
|
||||||
|
└── migrations/
|
||||||
|
└── 001_initial_schema.sql # All tables, indexes, RLS, triggers
|
||||||
|
```
|
||||||
|
|
||||||
|
Single migration file for the initial schema. Future changes get their own numbered migration files.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- All `TIMESTAMPTZ` columns store UTC. Application layer always sends UTC.
|
||||||
|
- `gen_random_uuid()` is available natively in Supabase (pgcrypto enabled).
|
||||||
|
- `slip_data` uses JSONB for flexibility — different bet types have different shapes.
|
||||||
|
- `legs` in scan_sessions is a UUID array referencing picks. Not a FK constraint (allows flexibility).
|
||||||
|
- Performance table uses a unique index on (user_id, period) — upsert on recalculation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. All 6 tables created successfully in Supabase
|
||||||
|
2. RLS enabled on every table
|
||||||
|
3. RLS policies enforce user-scoped access (user can only read/write their own data)
|
||||||
|
4. Service role key can bypass RLS (for backend operations)
|
||||||
|
5. `handle_new_user` trigger fires on auth.users insert, creating a public.users row
|
||||||
|
6. `updated_at` auto-updates on users table modifications
|
||||||
|
7. `scan_reset_date` logic resets scan_count when month rolls over
|
||||||
|
8. All indexes created
|
||||||
|
9. Constraints enforced: tier values, grade values, bet_type values, status values
|
||||||
|
10. All timestamps stored as TIMESTAMPTZ in UTC
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
### Schema Validation Tests
|
||||||
|
- Each table exists with correct columns and types
|
||||||
|
- All constraints reject invalid values (e.g., tier='gold' fails, grade='E' fails)
|
||||||
|
- Foreign keys enforce referential integrity (delete user cascades to picks, bets, etc.)
|
||||||
|
- Unique indexes prevent duplicates (one outcome per pick, one performance row per user+period)
|
||||||
|
|
||||||
|
### RLS Tests
|
||||||
|
- Authenticated user can SELECT their own rows from all tables
|
||||||
|
- Authenticated user CANNOT select another user's rows
|
||||||
|
- Authenticated user can INSERT into picks, bets, scan_sessions with their own user_id
|
||||||
|
- Authenticated user CANNOT insert with a different user_id
|
||||||
|
- Anon user cannot access any table
|
||||||
|
- Service role can read/write all rows (bypasses RLS)
|
||||||
|
|
||||||
|
### Trigger Tests
|
||||||
|
- Creating auth.users row auto-creates public.users row with correct email
|
||||||
|
- Updating users row auto-updates updated_at timestamp
|
||||||
|
- Scan count resets to 0 when scan_reset_date has passed
|
||||||
|
|
||||||
|
### Integration Tests (from Node.js)
|
||||||
|
- Supabase client with anon key + JWT can read user's own data
|
||||||
|
- Supabase client with service role can insert/read any data
|
||||||
|
- Full flow: create user -> insert pick -> insert outcome -> read performance
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- **scan_sessions.legs as UUID[]:** Using a Postgres array instead of a junction table. Simpler for now, but limits query flexibility. Acceptable for MVP; can migrate to a junction table if needed.
|
||||||
|
- **Performance recalculation:** Currently a stored row. Could instead be a Postgres view computed on the fly. Stored row chosen for read performance at scale. Backend job recalculates periodically.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
let serviceClient = null;
|
||||||
|
|
||||||
|
function getSupabaseClient() {
|
||||||
|
if (!client) {
|
||||||
|
client = createClient(
|
||||||
|
process.env.SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_ANON_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupabaseServiceClient() {
|
||||||
|
if (!serviceClient) {
|
||||||
|
serviceClient = createClient(
|
||||||
|
process.env.SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SERVICE_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return serviceClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSupabaseClient, getSupabaseServiceClient };
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
-- BetonBLK Initial Schema
|
||||||
|
-- Feature 1.4 — All tables, indexes, RLS policies, triggers
|
||||||
|
-- All timestamps use TIMESTAMPTZ (UTC)
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: users
|
||||||
|
-- Extends auth.users with app-specific profile data
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE public.users (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'analyst', 'desk')),
|
||||||
|
scan_count INT NOT NULL DEFAULT 0,
|
||||||
|
scan_reset_date TIMESTAMPTZ NOT NULL DEFAULT (date_trunc('month', now()) + interval '1 month'),
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
founder_status BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "users_select_own" ON public.users
|
||||||
|
FOR SELECT USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "users_update_own" ON public.users
|
||||||
|
FOR UPDATE USING (auth.uid() = id)
|
||||||
|
WITH CHECK (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "users_insert_own" ON public.users
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: picks
|
||||||
|
-- Individual prop analysis results from scans
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE public.picks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
player TEXT NOT NULL,
|
||||||
|
stat_type TEXT NOT NULL,
|
||||||
|
line NUMERIC(5,1) NOT NULL,
|
||||||
|
book TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK (direction IN ('over', 'under')),
|
||||||
|
grade TEXT NOT NULL CHECK (grade IN ('A', 'B', 'C', 'D')),
|
||||||
|
edge_pct NUMERIC(5,2),
|
||||||
|
reasoning TEXT,
|
||||||
|
kill_conditions TEXT[],
|
||||||
|
confidence NUMERIC(4,2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_picks_user_id ON public.picks(user_id);
|
||||||
|
CREATE INDEX idx_picks_created_at ON public.picks(created_at);
|
||||||
|
|
||||||
|
ALTER TABLE public.picks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "picks_select_own" ON public.picks
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "picks_insert_own" ON public.picks
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: scan_sessions
|
||||||
|
-- Groups picks into a single scan/parlay analysis session
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE public.scan_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
legs UUID[] NOT NULL DEFAULT '{}',
|
||||||
|
final_grade TEXT CHECK (final_grade IN ('A', 'B', 'C', 'D')),
|
||||||
|
kill_conditions TEXT[],
|
||||||
|
correlation_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_scan_sessions_user_id ON public.scan_sessions(user_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.scan_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "scan_sessions_select_own" ON public.scan_sessions
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "scan_sessions_insert_own" ON public.scan_sessions
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: bets
|
||||||
|
-- User-submitted bets (screenshot, quick slip, sportsbook sync)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE public.bets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
amount NUMERIC(10,2) NOT NULL,
|
||||||
|
potential_payout NUMERIC(10,2),
|
||||||
|
slip_data JSONB NOT NULL,
|
||||||
|
book TEXT NOT NULL,
|
||||||
|
bet_type TEXT NOT NULL CHECK (bet_type IN ('straight', 'parlay', 'teaser', 'round_robin')),
|
||||||
|
submission_method TEXT NOT NULL CHECK (submission_method IN ('screenshot', 'quickslip', 'sync')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'won', 'lost', 'push', 'void')),
|
||||||
|
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_bets_user_id ON public.bets(user_id);
|
||||||
|
CREATE INDEX idx_bets_status ON public.bets(status);
|
||||||
|
CREATE INDEX idx_bets_placed_at ON public.bets(placed_at);
|
||||||
|
|
||||||
|
ALTER TABLE public.bets ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "bets_select_own" ON public.bets
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bets_insert_own" ON public.bets
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "bets_update_own" ON public.bets
|
||||||
|
FOR UPDATE USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: outcomes
|
||||||
|
-- Actual results for each pick after game is played
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE public.outcomes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pick_id UUID NOT NULL REFERENCES public.picks(id) ON DELETE CASCADE,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('hit', 'miss', 'push')),
|
||||||
|
actual_value NUMERIC(5,1) NOT NULL,
|
||||||
|
logged_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_outcomes_pick_id ON public.outcomes(pick_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.outcomes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "outcomes_select_own" ON public.outcomes
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.picks
|
||||||
|
WHERE picks.id = outcomes.pick_id AND picks.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: performance
|
||||||
|
-- Aggregated performance metrics per user per period
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE public.performance (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly', 'all_time')),
|
||||||
|
roi NUMERIC(6,2),
|
||||||
|
win_rate NUMERIC(5,2),
|
||||||
|
sample_size INT NOT NULL DEFAULT 0,
|
||||||
|
total_wagered NUMERIC(10,2) DEFAULT 0,
|
||||||
|
total_profit NUMERIC(10,2) DEFAULT 0,
|
||||||
|
calculated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_performance_user_id ON public.performance(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_performance_user_period ON public.performance(user_id, period);
|
||||||
|
|
||||||
|
ALTER TABLE public.performance ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "performance_select_own" ON public.performance
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. Auto-create user profile on signup
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.users (id, email)
|
||||||
|
VALUES (NEW.id, NEW.email);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- 2. Auto-update updated_at on users table
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_updated_at
|
||||||
|
BEFORE UPDATE ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
|
||||||
|
|
||||||
|
-- 3. Monthly scan count reset
|
||||||
|
CREATE OR REPLACE FUNCTION public.reset_scan_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.scan_reset_date <= now() THEN
|
||||||
|
NEW.scan_count = 0;
|
||||||
|
NEW.scan_reset_date = date_trunc('month', now()) + interval '1 month';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_scan_reset
|
||||||
|
BEFORE UPDATE ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.reset_scan_count();
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const migrationPath = path.join(__dirname, '../../supabase/migrations/001_initial_schema.sql');
|
||||||
|
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||||
|
|
||||||
|
describe('Database Schema (001_initial_schema.sql)', () => {
|
||||||
|
describe('Table definitions', () => {
|
||||||
|
const expectedTables = ['users', 'picks', 'scan_sessions', 'bets', 'outcomes', 'performance'];
|
||||||
|
|
||||||
|
test.each(expectedTables)('defines CREATE TABLE for %s', (table) => {
|
||||||
|
const regex = new RegExp(`CREATE TABLE public\\.${table}\\s*\\(`, 'i');
|
||||||
|
expect(sql).toMatch(regex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RLS enabled', () => {
|
||||||
|
const tables = ['users', 'picks', 'scan_sessions', 'bets', 'outcomes', 'performance'];
|
||||||
|
|
||||||
|
test.each(tables)('enables RLS on %s', (table) => {
|
||||||
|
expect(sql).toContain(`ALTER TABLE public.${table} ENABLE ROW LEVEL SECURITY`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RLS policies', () => {
|
||||||
|
test('users has select, update, insert policies', () => {
|
||||||
|
expect(sql).toContain('CREATE POLICY "users_select_own"');
|
||||||
|
expect(sql).toContain('CREATE POLICY "users_update_own"');
|
||||||
|
expect(sql).toContain('CREATE POLICY "users_insert_own"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks has select and insert policies', () => {
|
||||||
|
expect(sql).toContain('CREATE POLICY "picks_select_own"');
|
||||||
|
expect(sql).toContain('CREATE POLICY "picks_insert_own"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan_sessions has select and insert policies', () => {
|
||||||
|
expect(sql).toContain('CREATE POLICY "scan_sessions_select_own"');
|
||||||
|
expect(sql).toContain('CREATE POLICY "scan_sessions_insert_own"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bets has select, insert, and update policies', () => {
|
||||||
|
expect(sql).toContain('CREATE POLICY "bets_select_own"');
|
||||||
|
expect(sql).toContain('CREATE POLICY "bets_insert_own"');
|
||||||
|
expect(sql).toContain('CREATE POLICY "bets_update_own"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outcomes has select policy with picks join', () => {
|
||||||
|
expect(sql).toContain('CREATE POLICY "outcomes_select_own"');
|
||||||
|
expect(sql).toContain('picks.user_id = auth.uid()');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('performance has select policy', () => {
|
||||||
|
expect(sql).toContain('CREATE POLICY "performance_select_own"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all policies use auth.uid()', () => {
|
||||||
|
const policyBlocks = sql.match(/CREATE POLICY[\s\S]*?;/g) || [];
|
||||||
|
for (const block of policyBlocks) {
|
||||||
|
expect(block).toContain('auth.uid()');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Constraints', () => {
|
||||||
|
test('users.tier constrained to free/analyst/desk', () => {
|
||||||
|
expect(sql).toMatch(/tier.*CHECK.*\(tier IN \('free',\s*'analyst',\s*'desk'\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks.grade constrained to A/B/C/D', () => {
|
||||||
|
expect(sql).toMatch(/grade.*CHECK.*\(grade IN \('A',\s*'B',\s*'C',\s*'D'\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks.direction constrained to over/under', () => {
|
||||||
|
expect(sql).toMatch(/direction.*CHECK.*\(direction IN \('over',\s*'under'\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bets.bet_type constrained', () => {
|
||||||
|
expect(sql).toMatch(/bet_type.*CHECK.*\(bet_type IN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bets.status constrained', () => {
|
||||||
|
expect(sql).toMatch(/status.*CHECK.*\(status IN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outcomes.result constrained to hit/miss/push', () => {
|
||||||
|
expect(sql).toMatch(/result.*CHECK.*\(result IN \('hit',\s*'miss',\s*'push'\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('performance.period constrained', () => {
|
||||||
|
expect(sql).toMatch(/period.*CHECK.*\(period IN/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Indexes', () => {
|
||||||
|
test('picks has user_id and created_at indexes', () => {
|
||||||
|
expect(sql).toContain('CREATE INDEX idx_picks_user_id');
|
||||||
|
expect(sql).toContain('CREATE INDEX idx_picks_created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bets has user_id, status, and placed_at indexes', () => {
|
||||||
|
expect(sql).toContain('CREATE INDEX idx_bets_user_id');
|
||||||
|
expect(sql).toContain('CREATE INDEX idx_bets_status');
|
||||||
|
expect(sql).toContain('CREATE INDEX idx_bets_placed_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outcomes has unique index on pick_id', () => {
|
||||||
|
expect(sql).toContain('CREATE UNIQUE INDEX idx_outcomes_pick_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('performance has unique index on user_id + period', () => {
|
||||||
|
expect(sql).toContain('CREATE UNIQUE INDEX idx_performance_user_period');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Foreign keys', () => {
|
||||||
|
test('users references auth.users with CASCADE', () => {
|
||||||
|
expect(sql).toMatch(/users[\s\S]*?REFERENCES auth\.users\(id\) ON DELETE CASCADE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks references users with CASCADE', () => {
|
||||||
|
expect(sql).toMatch(/picks[\s\S]*?REFERENCES public\.users\(id\) ON DELETE CASCADE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outcomes references picks with CASCADE', () => {
|
||||||
|
expect(sql).toMatch(/outcomes[\s\S]*?REFERENCES public\.picks\(id\) ON DELETE CASCADE/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Triggers', () => {
|
||||||
|
test('handle_new_user trigger exists', () => {
|
||||||
|
expect(sql).toContain('CREATE OR REPLACE FUNCTION public.handle_new_user()');
|
||||||
|
expect(sql).toContain('CREATE TRIGGER on_auth_user_created');
|
||||||
|
expect(sql).toContain('AFTER INSERT ON auth.users');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updated_at trigger exists', () => {
|
||||||
|
expect(sql).toContain('CREATE OR REPLACE FUNCTION public.update_updated_at()');
|
||||||
|
expect(sql).toContain('CREATE TRIGGER users_updated_at');
|
||||||
|
expect(sql).toContain('BEFORE UPDATE ON public.users');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan count reset trigger exists', () => {
|
||||||
|
expect(sql).toContain('CREATE OR REPLACE FUNCTION public.reset_scan_count()');
|
||||||
|
expect(sql).toContain('CREATE TRIGGER users_scan_reset');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Timestamps', () => {
|
||||||
|
test('all timestamp columns use TIMESTAMPTZ', () => {
|
||||||
|
// Ensure no plain TIMESTAMP without TZ
|
||||||
|
const timestampMatches = sql.match(/\bTIMESTAMP\b(?!TZ)/gi) || [];
|
||||||
|
expect(timestampMatches.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user