# Feature 1.4 — Database Schema (Supabase + RLS)

## Overview
Complete PostgreSQL schema in Supabase for all VYNDR 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.
