users — Preserved| Column | Type | Changes |
|---|---|---|
| id | uuid PK | — |
| varchar | — | |
| ... | (all existing columns) | — |
Role: Authentication and identity. Each user can own fantasy teams and participate in fantasy leagues.
Derives from: src/db/schema/users.ts — preserved as-is.
Justification: No audit finding against this table. Auth integration (Supabase) is a preserved
primitive per the audit (Scenario B).
fantasy_leagues — Preserved + modifiedDerives from: src/db/schema/fantasy_leagues.ts
Justification: Core domain entity. Structure, status, and ownership preserved.
Role: A fantasy league is the competition container. Users join as participants, each fields a fantasy team, and compete across matchweek windows. The league defines scoring rules, team limits, and season boundaries.
| Column | Type | Change |
|---|---|---|
| id | uuid PK | — |
| name | varchar(255) | — |
| description | text | — |
| ownerId | uuid FK → users.id | — |
| season | integer NOT NULL | NEW — start year of the season (e.g., 2025 for 2025-2026) |
| scoringRules | jsonb | — |
| maxTeams | integer | — |
| isPrivate | boolean | — |
| inviteCode | varchar(255) | — |
| status | varchar(20) | — |
| format | varchar(20) | NEW — values: BLITZ, MID, FULL |
| createdAt | timestamp | — |
| updatedAt | timestamp | — |
Season convention:
European football leagues span two calendar years (e.g., August 2025 to May 2026). The season
column stores the start year as an integer, following the API-Football convention:
| season value | Meaning | Example date range |
|---|---|---|
2025 |
2025-2026 season | Aug 2025 – May 2026 |
2024 |
2024-2025 season | Aug 2024 – May 2025 |
2026 |
2026-2027 season | Aug 2026 – May 2027 |
This matches the API-Football v3 convention where GET /fixtures?league=39&season=2025 returns
matches for the 2025-2026 season. The exact start/end dates are stored on soccer_leagues.season_start
and soccer_leagues.season_end.
Constraint chain — how leagues tie to a season:
fantasy_leagues.season = 2025
│
├──▶ fantasy_league_game_limits
│ Links to soccer_leagues WHERE season = 2025
│ Constraint: soccer league season must match fantasy league season
│
├──▶ draft_settings.active_season = 2025
│ Validated against fantasy_leagues.season
│
└──▶ point_calculation
Filters matches WHERE soccer_leagues.season = fantasy_leagues.season
(replaces global getCurrentSeasonYear() call)
Columns removed:
startMatchweek, endMatchweek — hardcoded; replaced by deriving from active season via format.currentMatchweek, footballMatchweek — duplicated; derive from fantasy_matchweek_windows.draftCompleted, draftInProgress, draftDate, draftOrder, currentDraftTurn,
draftTimePerPick, draftPickCount, autoPickEnabled, isMultiplayer, serverStartTime,
lastPickTime — all belong in draft_settings.countMultipleGamesInWindow, gamesPerWindowLimit — move to league rules jsonb.isCompleted — redundant with status = 'COMPLETED'.startDate, endDate — derive from season data.currentRound, currentPick, pickTimeLimit, joinCode — belong in draft_settings.rules — kept in jsonb rules.rosterLockHours — kept in league rules.maxTeamsPerParticipant — removed (see §4 — Evaluation).New CHECK constraints:
-- Team count constraint
CHECK (
(is_private = false AND max_teams = 6) OR
(is_private = true AND max_teams BETWEEN 4 AND 10)
)
-- Season sanity check
CHECK (season >= 2020 AND season <= 2100)
fantasy_league_participants — ModifiedDerives from: src/db/schema/fantasy_league_participants.ts
Justification: Standard M:N relation between users and fantasy leagues.
Role: Links a user to a fantasy league as a participant. Each participant fields exactly one
fantasy team within the league (enforced by the unique index on fantasy_teams(user_id, league_id)).
This table is the "seat" at the table — it also determines draft order via draft_order.
| Column | Type | Change |
|---|---|---|
| id | uuid PK | — |
| leagueId | uuid FK → fantasy_leagues.id | — |
| userId | uuid FK → users.id | — |
| fantasyTeamId | uuid FK → fantasy_teams.id | — |
| joinedAt | timestamp | — |
| createdAt | timestamp | — |
| updatedAt | timestamp | — |
Columns removed:
currentPosition — duplicated; lives on fantasy_rankings.totalPoints — duplicated; lives on fantasy_teams.totalPoints / fantasy_team_points.Relationships:
fantasy_teams — PreservedDerives from: src/db/schema/fantasy_teams.ts
Justification: Core domain entity. Ownership and league assignment preserved.
Role: A fantasy team is the user's construct within a fantasy league. It has a budget, roster
of real clubs (fantasy_roster_entries), points, and ranking. One team per user per league
(enforced by unique index on (user_id, league_id)).
Key columns:
userId — owner of this fantasy team.leagueId — which fantasy league it belongs to.budgetRemaining — starting budget (default 100) minus purchases.totalPoints — cumulative points across all windows.rank — current overall rank within the league.transfersRemaining — free transfers left this window.soccer_teams — Renamed (was teams)Derives from: src/db/schema/teams.ts
Justification: Real-world clubs. Preserved as-is, only renamed for clarity.
Role: Real-world football clubs (e.g., River Plate, Boca Juniors, Manchester City).
These are the assets that fantasy teams draft, roster, and activate. Soccer teams belong to
real-world competitions (soccer_leagues) and play matches against each other.
| Column | Type | Change |
|---|---|---|
| id | uuid PK | — |
| apiId | varchar(255) | — |
| name | varchar(255) | — |
| tla | varchar(3) | — |
| shortName | varchar(255) | — |
| logoUrl | varchar(255) | — |
| leagueId | uuid FK → soccer_leagues.id | — |
| isActive | boolean | — |
Relationships:
soccer_leagues — Renamed (was leagues)Derives from: src/db/schema/leagues.ts
Justification: Real-world competitions (4 leagues). Preserved as-is, only renamed for clarity.
Role: Real-world leagues/competitions (e.g., Premier League, La Liga, Primera División).
These are the source of truth for match schedules, matchdays, and real team standings.
Fantasy leagues reference these indirectly through fantasy_league_game_limits.
| Column | Type | Change |
|---|---|---|
| id | uuid PK | — |
| apiId | varchar(255) | — |
| name | varchar(255) | — |
| code | varchar(10) | — |
| season | integer | Start year of the season (e.g., 2025 for 2025-2026) |
| seasonStart | timestamp | Exact start date (e.g., 2025-08-15) |
| seasonEnd | timestamp | Exact end date (e.g., 2026-05-24) |
| currentMatchweek | integer | — |
Season convention:
The season column stores the start year as an integer, matching the API-Football v3 convention.
This is the same format used by fantasy_leagues.season — the two must align via the
fantasy_league_game_limits constraint.
| API endpoint | Parameter | Value | Meaning |
|---|---|---|---|
GET /leagues?id=39&season=2025 |
season |
2025 |
Premier League 2025-2026 |
GET /fixtures?league=39&season=2025 |
season |
2025 |
Matches for 2025-2026 |
GET /standings?league=39&season=2025 |
season |
2025 |
Standings for 2025-2026 |
Unique constraint:
CONSTRAINT unique_league_season UNIQUE (api_id, season)
Relationships:
soccer_matches — Renamed (was matches)Derives from: src/db/schema/matches.ts
Justification: Core entity. Audit flagged missing FKs and varchar PK.
Role: Real-world matches between two soccer teams. Results feed into point calculations for fantasy teams that have those clubs active.
| Column | Type | Change |
|---|---|---|
| id | uuid PK | NEW (was varchar PK) |
| apiId | varchar | NEW — holds the original football-data.org ID |
| competitionId | uuid FK → soccer_leagues.id | CHANGED from varchar to real FK |
| homeTeamId | uuid FK → soccer_teams.id | CHANGED from varchar to real FK |
| awayTeamId | uuid FK → soccer_teams.id | CHANGED from varchar to real FK |
| utcDate | timestamp | — |
| status | varchar | — |
| matchday | integer | — |
| homeScore | integer | — |
| awayScore | integer | — |
| ... | (remaining columns preserved) | — |
point_calculation_events — PreservedDerives from: src/db/schema/point_calculation_events.ts
Justification: Core scoring table. Add matchId real FK.
Role: Records every point-awarding event for a fantasy team's club in a matchweek window. Each event has a type (MATCH_OUTCOME, CLEAN_SHEET, GOALS_SCORED, etc.), a category (BASE/BONUS/PENALTY), and a status lifecycle (PENDING → CONFIRMED → DISPUTED → ADJUSTED).
Constraints activated (dormant, already exported in current code):
match_id_required_for_match_eventsvalid_point_categoryvalid_status_transitionsfantasy_team_points — Renamed (was fantasy_team_window_points)Derives from: src/db/schema/fantasy_team_window_points.ts
Justification: Points per window per team. The "window" qualifier is redundant — all points are per-window by context.
Role: Aggregated points for a fantasy team in a single matchweek window. The total is computed
as base + bonus - penalty and cross-checked against individual point_calculation_events.
Also carries the per-window rank and rank change.
Constraints activated (dormant, already exported in current code):
unique_team_window_pointspoints_calculation_checkbase_points_match_eventsbonus_points_match_eventspenalty_points_match_eventsvalid_status_transitionsunique_rank_per_windowtransfer_requests — PreservedDerives from: src/db/schema/transfer_requests.ts
Justification: Core transfer system.
Role: A fantasy team's request to swap one real club for another in an upcoming transfer window. Each request has a priority (1–3) and references both the team to add and the team to remove.
Constraints activated (dormant, already exported in current code):
valid_priority_rangevalid_user_order_rangeunique_priority_per_windowunique_user_order_per_windowrequests_per_window_limittransfer_windows — PreservedDerives from: src/db/schema/transfer_windows.ts
Justification: Window lifecycle.
Role: A time-bounded period during which fantasy teams can submit and process transfer requests.
Linked 1:1 to a fantasy_matchweek_window.
transfer_history — PreservedDerives from: src/db/schema/transfer_history.ts
Justification: Audit trail.
Role: Immutable log of every completed transfer. Records which club was removed, which was added, who authorized it, and the fantasy team's rank/points at the time.
draft_settings — ModifiedDerives from: src/db/schema/draft.ts
Justification: Draft lifecycle.
Role: Configuration for a fantasy league's initial draft. Controls timing, pick order, auto-pick
behavior, and current round/pick tracking. One draft_settings per fantasy league.
Season handling:
The current draft_settings has an independent active_season column (default 2025). In v2,
this is replaced by referencing fantasy_leagues.season — the season is a property of the league,
not the draft.
| Column | Type | Change |
|---|---|---|
| id | uuid PK | — |
| leagueId | uuid FK → fantasy_leagues.id | — |
| active_season | removed | Derived from fantasy_leagues.season |
| pickTimeLimit | integer | — |
| status | varchar(20) | — |
| currentRound | integer | — |
| currentPick | integer | — |
| ... | (other columns) | — |
Validation: When creating a draft, the active_season is read from fantasy_leagues.season.
When drafting a team, the pick endpoint validates that the team's soccer league season matches
the fantasy league's season (currently done via draft_settings.active_season, line 164 of
src/pages/api/draft/[leagueId]/pick.ts).
draft_picks — ModifiedDerives from: src/db/schema/draft.ts
Justification: Add real FK to draft_settings so Drizzle Studio does not crash. Add
fantasyTeamId to make ownership explicit.
Role: Records each pick made during a draft. A pick assigns a real club (soccer_team) to a
fantasy team. The fantasyTeamId column makes the owner explicit (previously inferred via
(user_id, league_id) → fantasy_teams).
| Column | Type | Change |
|---|---|---|
| id | uuid PK | — |
| draftId | uuid FK → draft_settings.id | NEW |
| fantasyTeamId | uuid FK → fantasy_teams.id | NEW — makes ownership explicit |
| leagueId | uuid FK → fantasy_leagues.id | — |
| userId | uuid FK → users.id | — |
| teamId | uuid FK → soccer_teams.id | — |
| round | integer | — |
| pickNumber | integer | — |
| isAutoPick | boolean | — |
| pickedAt | timestamp | — |
draft_order — PreservedDerives from: src/db/schema/draft.ts
Justification: Draft order tracking.
Role: Defines the pick order for participants in a draft. Each participant gets a pick_order
value, and the draft snakes through the order.
fantasy_matchweek_windows — PreservedDerives from: src/db/schema/fantasy_matchweeks.ts
Justification: Window lifecycle (UPCOMING → ACTIVE → LOCKED → PROCESSING → COMPLETED).
Role: A time-bounded window corresponding to a fantasy matchweek. Windows group real-world matches that count toward a single scoring period. Each window has a lock time (when lineups freeze) and an unlock time (when points are finalized).
fantasy_roster_games — Renamed (was fantasy_team_games_played)Derives from: src/db/schema/fantasy_team_games_played.ts
Justification: Games-played tracking for Match Cap enforcement. Renamed to match the
roster_entries / roster_games naming convention.
Role: Tracks each individual match a fantasy team's owned club has played within a window.
Used to enforce the games-per-window limit (Match Cap). Each row corresponds to one roster entry
(fantasy_roster_entries) playing in one real match.
Relationship diagram:
fantasy_roster_entries (ownership)
│
├──▶ window_lineups (active/inactive per window)
│ │
│ └──▶ fantasy_roster_games (which matches the club played)
│
└──▶ point_calculation_events (points earned from those matches)
fantasy_rankings — Renamed (was fantasy_team_rankings)Derives from: src/db/schema/fantasy_team_rankings.ts
Justification: Rankings/standings. The "team" qualifier is redundant — only fantasy teams are ranked.
Role: Per-window snapshot of each fantasy team's rank within their league. Stores both overall rank and window-specific rank, plus rank change.
fantasy_league_game_limits — Renamed (was league_game_limits)Derives from: src/db/schema/league_game_limits.ts
Justification: Game limits per league. Renamed to clarify it's a fantasy-league concept.
Role: Defines how many games from each real-world competition (soccer_league) count toward
a fantasy league's scoring windows. Links fantasy_leagues to soccer_leagues with a max games
per team threshold.
Critical constraint: The linked soccer_league must have the same season as the
fantasy_league. This prevents linking a 2025 fantasy league to a 2024 soccer league.
| FK | References | Purpose |
|---|---|---|
fantasy_league_id |
fantasy_leagues.id |
Which fantasy league this limit applies to |
league_id |
soccer_leagues.id |
Which real-world competition is being limited |
CHECK constraint (season alignment):
CONSTRAINT matching_season CHECK (
league_id IN (
SELECT sl.id FROM soccer_leagues sl
WHERE sl.season = (
SELECT fl.season FROM fantasy_leagues fl
WHERE fl.id = fantasy_league_id
)
)
)
Composite unique constraint:
CONSTRAINT unique_fantasy_soccer_league UNIQUE (fantasy_league_id, league_id)
soccer_league_standings — Renamed (was league_standings)Derives from: src/db/schema/league_standings.ts
Justification: Standings snapshots from the real world. Renamed to clarify this is a real-world concept.
Role: Periodic snapshots of real-world league standings per matchweek. Used as input for point calculations and scouting.
notifications — PreservedDerives from: src/db/schema/notifications.ts
Justification: User notification system.
fantasy_league_invitations — PreservedDerives from: src/db/schema/fantasy_league_invitations.ts
Justification: League invite system.
system_logs — PreservedDerives from: src/db/schema/system_logs.ts
Justification: Audit logging.
email_verification_tokens — PreservedDerives from: src/db/schema/email_verification_tokens.ts
Justification: Auth support.
password_reset_tokens — PreservedDerives from: src/db/schema/password_reset_tokens.ts
Justification: Auth support.
window_lineups — Consolidated (NEW)Derives from: active_rosters + active_rosters_history + fantasy_roster_entries.isActive
Justification: The audit found three tables managing the same concept (which clubs are active
for a fantasy team in a given window). They collapse into one versioned snapshot table.
The point-calculation.ts:117-122 snapshot-at-lock-time logic reads from this table — only the
source changes, the algorithm stays identical.
Role: A per-window snapshot of which real clubs a fantasy team has activated for scoring.
When a window locks, this snapshot freezes — clubs activated after the lock do not count.
This replaces active_rosters (current active state), active_rosters_history (versioned history),
and the isActive column on fantasy_roster_entries (redundant boolean flag).
CREATE TABLE window_lineups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
fantasy_team_id uuid NOT NULL REFERENCES fantasy_teams(id),
window_id uuid NOT NULL REFERENCES fantasy_matchweek_windows(id),
team_id uuid NOT NULL REFERENCES soccer_teams(id),
position integer NOT NULL DEFAULT 0, -- slot 1-4
is_active boolean NOT NULL DEFAULT true,
version integer NOT NULL DEFAULT 1,
locked_at timestamp, -- when window locked, this snapshot freezes
activated_at timestamp NOT NULL DEFAULT now(),
deactivated_at timestamp,
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now(),
CONSTRAINT unique_team_per_window UNIQUE (fantasy_team_id, window_id, team_id)
);
Lifecycle:
Window UPCOMING → user can freely modify lineups
Window LOCKS → `locked_at` is set, lineup freezes
Window PROCESSING → points calculated against frozen lineup
Window COMPLETED → final state, lineup is historical
fantasy_roster_entries — Renamed (was fantasy_team_players)Derives from: src/db/schema/fantasy_team_players.ts
Justification: Still tracks which real clubs a fantasy team owns (roster). Renamed from
fantasy_team_players to fantasy_roster_entries because the table stores real clubs, not
"players" — the old name misled every new developer. The weeklyPoints, totalPoints, points
columns are removed (grep-confirmed dead). The isActive column is removed — active status now
lives in window_lineups.
Role: The roster table — defines which real clubs a fantasy team has acquired (via draft or
transfer). This is the "collection" or "portfolio" of owned clubs. It does NOT track which clubs
are currently active — that is window_lineups's job. It does NOT track which games were played
— that is fantasy_roster_games's job.
Columns removed: isActive, weeklyPoints, totalPoints, points
Relationship summary:
fantasy_roster_entries (ownership — what clubs do I own?)
│ owned clubs are available for activation
▼
window_lineups (which owned clubs are active THIS window)
│
▼
fantasy_roster_games (which matches did each active club play?)
public_league_pool — NEWDerives from: N/A (new table)
Justification: Spec requires public league join with overflow queue. When no public league is available, the user enters the pool. A periodic task creates leagues from pooled users. No merge logic — the join endpoint already prioritizes existing leagues with space.
CREATE TABLE public_league_pool (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
format varchar(20) NOT NULL CHECK (format IN ('BLITZ', 'MID', 'FULL')),
status varchar(20) NOT NULL DEFAULT 'WAITING'
CHECK (status IN ('WAITING', 'ASSIGNED', 'EXPIRED')),
created_at timestamp NOT NULL DEFAULT now(),
CONSTRAINT one_pool_entry_per_user UNIQUE (user_id, format, status)
);