package db import ( "context" "time" "github.com/pkg/errors" "github.com/uptrace/bun" ) // PlayoffFormat represents the bracket format based on team count type PlayoffFormat string const ( // PlayoffFormat5to6 is for 5-6 teams: top 5 qualify, double-elimination style PlayoffFormat5to6 PlayoffFormat = "5-6-teams" // PlayoffFormat7to9 is for 7-9 teams: top 6 qualify, seeded bracket PlayoffFormat7to9 PlayoffFormat = "7-9-teams" // PlayoffFormat10to15 is for 10-15 teams: top 8 qualify PlayoffFormat10to15 PlayoffFormat = "10-15-teams" ) // PlayoffStatus represents the current state of a playoff bracket type PlayoffStatus string const ( PlayoffStatusUpcoming PlayoffStatus = "upcoming" PlayoffStatusInProgress PlayoffStatus = "in_progress" PlayoffStatusCompleted PlayoffStatus = "completed" ) // SeriesStatus represents the current state of a playoff series type SeriesStatus string const ( SeriesStatusPending SeriesStatus = "pending" SeriesStatusInProgress SeriesStatus = "in_progress" SeriesStatusCompleted SeriesStatus = "completed" SeriesStatusBye SeriesStatus = "bye" ) // PlayoffBracket is the top-level container for a league's playoff bracket type PlayoffBracket struct { bun.BaseModel `bun:"table:playoff_brackets,alias:pb"` ID int `bun:"id,pk,autoincrement"` SeasonID int `bun:",notnull,unique:season_league"` LeagueID int `bun:",notnull,unique:season_league"` Format PlayoffFormat `bun:",notnull"` Status PlayoffStatus `bun:",notnull,default:'upcoming'"` CreatedAt int64 `bun:",notnull"` UpdatedAt *int64 `bun:"updated_at"` Season *Season `bun:"rel:belongs-to,join:season_id=id"` League *League `bun:"rel:belongs-to,join:league_id=id"` Series []*PlayoffSeries `bun:"rel:has-many,join:id=bracket_id"` } // PlayoffSeries represents a single matchup (potentially best-of-N) in the bracket type PlayoffSeries struct { bun.BaseModel `bun:"table:playoff_series,alias:ps"` ID int `bun:"id,pk,autoincrement"` BracketID int `bun:",notnull"` SeriesNumber int `bun:",notnull"` // Display order within bracket Round string `bun:",notnull"` // e.g. "qualifying_final", "semi_final", "grand_final" Label string `bun:",notnull"` // Human-readable label e.g. "QF1", "SF2", "Grand Final" Team1ID *int `bun:"team1_id"` Team2ID *int `bun:"team2_id"` Team1Seed *int `bun:"team1_seed"` // Original seeding position (1st, 2nd, etc.) Team2Seed *int `bun:"team2_seed"` // Original seeding position WinnerTeamID *int `bun:"winner_team_id"` // Set when series is decided LoserTeamID *int `bun:"loser_team_id"` // Set when series is decided MatchesToWin int `bun:",notnull,default:1"` // 1 = single match, 2 = Bo3, 3 = Bo5, etc. Team1Wins int `bun:",notnull,default:0"` // Matches won by team1 Team2Wins int `bun:",notnull,default:0"` // Matches won by team2 Status SeriesStatus `bun:",notnull,default:'pending'"` // pending, in_progress, completed, bye WinnerNextID *int `bun:"winner_next_series_id"` // Series the winner advances to WinnerNextSlot *string `bun:"winner_next_slot"` // "team1" or "team2" in next series LoserNextID *int `bun:"loser_next_series_id"` // Series the loser drops to (double-elim) LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series CreatedAt int64 `bun:",notnull"` Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"` Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"` Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"` Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"` Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"` Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"` } // PlayoffMatch represents a single game within a series type PlayoffMatch struct { bun.BaseModel `bun:"table:playoff_matches,alias:pm"` ID int `bun:"id,pk,autoincrement"` SeriesID int `bun:",notnull"` MatchNumber int `bun:",notnull"` // 1-indexed: game 1, game 2, etc. HomeTeamID *int `bun:"home_team_id"` AwayTeamID *int `bun:"away_team_id"` FixtureID *int `bun:"fixture_id"` // Links to existing fixture system Status string `bun:",notnull,default:'pending'"` CreatedAt int64 `bun:",notnull"` Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"` Home *Team `bun:"rel:belongs-to,join:home_team_id=id"` Away *Team `bun:"rel:belongs-to,join:away_team_id=id"` Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` } // NewPlayoffBracket creates a new playoff bracket for a season+league func NewPlayoffBracket( ctx context.Context, tx bun.Tx, seasonID, leagueID int, format PlayoffFormat, audit *AuditMeta, ) (*PlayoffBracket, error) { bracket := &PlayoffBracket{ SeasonID: seasonID, LeagueID: leagueID, Format: format, Status: PlayoffStatusUpcoming, CreatedAt: time.Now().Unix(), } err := Insert(tx, bracket).WithAudit(audit, &AuditInfo{ Action: "playoffs.create_bracket", ResourceType: "playoff_bracket", ResourceID: nil, Details: map[string]any{ "season_id": seasonID, "league_id": leagueID, "format": string(format), }, }).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "Insert") } return bracket, nil } // GetPlayoffBracket retrieves a playoff bracket for a season+league with all series and teams func GetPlayoffBracket( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) (*PlayoffBracket, error) { bracket := new(PlayoffBracket) err := tx.NewSelect(). Model(bracket). Where("pb.season_id = ?", seasonID). Where("pb.league_id = ?", leagueID). Relation("Season"). Relation("League"). Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Order("ps.series_number ASC") }). Relation("Series.Team1"). Relation("Series.Team2"). Relation("Series.Winner"). Relation("Series.Loser"). Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Order("pm.match_number ASC") }). Scan(ctx) if err != nil { if err.Error() == "sql: no rows in result set" { return nil, nil } return nil, errors.Wrap(err, "tx.NewSelect") } return bracket, nil } // GetPlayoffBracketByID retrieves a playoff bracket by ID with all series func GetPlayoffBracketByID( ctx context.Context, tx bun.Tx, bracketID int, ) (*PlayoffBracket, error) { return GetByID[PlayoffBracket](tx, bracketID). Relation("Season"). Relation("League"). Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Order("ps.series_number ASC") }). Relation("Series.Team1"). Relation("Series.Team2"). Relation("Series.Winner"). Relation("Series.Loser"). Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery { return q.Order("pm.match_number ASC") }). Get(ctx) } // NewPlayoffSeries creates a new series within a bracket func NewPlayoffSeries( ctx context.Context, tx bun.Tx, bracket *PlayoffBracket, seriesNumber int, round, label string, team1ID, team2ID *int, team1Seed, team2Seed *int, matchesToWin int, status SeriesStatus, ) (*PlayoffSeries, error) { series := &PlayoffSeries{ BracketID: bracket.ID, SeriesNumber: seriesNumber, Round: round, Label: label, Team1ID: team1ID, Team2ID: team2ID, Team1Seed: team1Seed, Team2Seed: team2Seed, MatchesToWin: matchesToWin, Status: status, CreatedAt: time.Now().Unix(), } err := Insert(tx, series).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "Insert") } return series, nil } // SetSeriesAdvancement sets the advancement links for a series func SetSeriesAdvancement( ctx context.Context, tx bun.Tx, seriesID int, winnerNextID *int, winnerNextSlot *string, loserNextID *int, loserNextSlot *string, ) error { _, err := tx.NewUpdate(). Model((*PlayoffSeries)(nil)). Set("winner_next_series_id = ?", winnerNextID). Set("winner_next_slot = ?", winnerNextSlot). Set("loser_next_series_id = ?", loserNextID). Set("loser_next_slot = ?", loserNextSlot). Where("id = ?", seriesID). Exec(ctx) if err != nil { return errors.Wrap(err, "tx.NewUpdate") } return nil } // CountUnplayedFixtures counts fixtures without finalized results for a season+league func CountUnplayedFixtures( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) (int, error) { count, err := tx.NewSelect(). Model((*Fixture)(nil)). Where("f.season_id = ?", seasonID). Where("f.league_id = ?", leagueID). Where("f.game_week IS NOT NULL"). Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)"). Count(ctx) if err != nil { return 0, errors.Wrap(err, "tx.NewSelect.Count") } return count, nil } // GetUnplayedFixtures returns all fixtures without finalized results for a season+league func GetUnplayedFixtures( ctx context.Context, tx bun.Tx, seasonID, leagueID int, ) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("f.season_id = ?", seasonID). Where("f.league_id = ?", leagueID). Where("f.game_week IS NOT NULL"). Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)"). Order("f.game_week ASC", "f.round ASC", "f.id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } return fixtures, nil } // AutoForfeitUnplayedFixtures creates mutual forfeit results for all unplayed fixtures func AutoForfeitUnplayedFixtures( ctx context.Context, tx bun.Tx, seasonID, leagueID int, userID int, audit *AuditMeta, ) (int, error) { unplayed, err := GetUnplayedFixtures(ctx, tx, seasonID, leagueID) if err != nil { return 0, errors.Wrap(err, "GetUnplayedFixtures") } reason := "Auto-forfeited: regular season ended for finals" for _, fixture := range unplayed { // Check if a result already exists (non-finalized) existing, err := GetFixtureResult(ctx, tx, fixture.ID) if err != nil { return 0, errors.Wrap(err, "GetFixtureResult") } if existing != nil { // Skip fixtures that already have any result continue } _, err = CreateForfeitResult(ctx, tx, fixture, ForfeitTypeMutual, "", reason, userID, audit) if err != nil { return 0, errors.Wrap(err, "CreateForfeitResult") } } return len(unplayed), nil }