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:
@@ -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.
|
||||
Reference in New Issue
Block a user