548 lines
16 KiB
Go
548 lines
16 KiB
Go
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
|
|
}
|
|
|
|
// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed
|
|
// for the series detail page.
|
|
func GetPlayoffSeriesByID(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
seriesID int,
|
|
) (*PlayoffSeries, error) {
|
|
series := new(PlayoffSeries)
|
|
err := tx.NewSelect().
|
|
Model(series).
|
|
Where("ps.id = ?", seriesID).
|
|
Relation("Bracket").
|
|
Relation("Bracket.Season").
|
|
Relation("Bracket.League").
|
|
Relation("Bracket.Series").
|
|
Relation("Team1").
|
|
Relation("Team2").
|
|
Relation("Winner").
|
|
Relation("Loser").
|
|
Relation("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 series, nil
|
|
}
|
|
|
|
// CanScheduleSeries checks if the user is a manager of one of the teams in the series.
|
|
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
|
|
// Both teams must be assigned for scheduling to be possible.
|
|
func CanScheduleSeries(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
series *PlayoffSeries,
|
|
user *User,
|
|
) (bool, int, error) {
|
|
if user == nil || user.Player == nil {
|
|
return false, 0, nil
|
|
}
|
|
if series.Team1ID == nil || series.Team2ID == nil {
|
|
return false, 0, nil
|
|
}
|
|
|
|
roster := new(TeamRoster)
|
|
err := tx.NewSelect().
|
|
Model(roster).
|
|
Column("team_id", "is_manager").
|
|
Where("team_id IN (?)", bun.In([]int{*series.Team1ID, *series.Team2ID})).
|
|
Where("season_id = ?", series.Bracket.SeasonID).
|
|
Where("league_id = ?", series.Bracket.LeagueID).
|
|
Where("player_id = ?", user.Player.ID).
|
|
Scan(ctx)
|
|
if err != nil {
|
|
if err.Error() == "sql: no rows in result set" {
|
|
return false, 0, nil
|
|
}
|
|
return false, 0, errors.Wrap(err, "tx.NewSelect")
|
|
}
|
|
if !roster.IsManager {
|
|
return false, 0, nil
|
|
}
|
|
return true, roster.TeamID, nil
|
|
}
|
|
|
|
// GetSeriesTeamRosters returns rosters for both teams in a series.
|
|
// Returns map["team1"|"team2"] -> []*PlayerWithPlayStatus
|
|
func GetSeriesTeamRosters(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
series *PlayoffSeries,
|
|
) (map[string][]*PlayerWithPlayStatus, error) {
|
|
if series == nil {
|
|
return nil, errors.New("series cannot be nil")
|
|
}
|
|
|
|
rosters := map[string][]*PlayerWithPlayStatus{}
|
|
|
|
if series.Team1ID != nil {
|
|
team1Rosters := []*TeamRoster{}
|
|
err := tx.NewSelect().
|
|
Model(&team1Rosters).
|
|
Where("tr.team_id = ?", *series.Team1ID).
|
|
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
|
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
|
return q.Relation("User")
|
|
}).
|
|
Scan(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "tx.NewSelect team1 roster")
|
|
}
|
|
for _, tr := range team1Rosters {
|
|
rosters["team1"] = append(rosters["team1"], &PlayerWithPlayStatus{
|
|
Player: tr.Player,
|
|
Played: false,
|
|
IsManager: tr.IsManager,
|
|
})
|
|
}
|
|
}
|
|
|
|
if series.Team2ID != nil {
|
|
team2Rosters := []*TeamRoster{}
|
|
err := tx.NewSelect().
|
|
Model(&team2Rosters).
|
|
Where("tr.team_id = ?", *series.Team2ID).
|
|
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
|
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
|
return q.Relation("User")
|
|
}).
|
|
Scan(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "tx.NewSelect team2 roster")
|
|
}
|
|
for _, tr := range team2Rosters {
|
|
rosters["team2"] = append(rosters["team2"], &PlayerWithPlayStatus{
|
|
Player: tr.Player,
|
|
Played: false,
|
|
IsManager: tr.IsManager,
|
|
})
|
|
}
|
|
}
|
|
|
|
return rosters, nil
|
|
}
|
|
|
|
// ComputeSeriesPreview computes standings comparison data for the two teams in a series.
|
|
// Uses the same logic as ComputeMatchPreview but takes a series instead of a fixture.
|
|
func ComputeSeriesPreview(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
series *PlayoffSeries,
|
|
) (*MatchPreviewData, error) {
|
|
if series == nil || series.Bracket == nil {
|
|
return nil, errors.New("series and bracket cannot be nil")
|
|
}
|
|
|
|
seasonID := series.Bracket.SeasonID
|
|
leagueID := series.Bracket.LeagueID
|
|
|
|
// Get all teams in this season+league
|
|
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, seasonID, leagueID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
|
}
|
|
|
|
// Get all allocated fixtures for the season+league
|
|
allFixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
|
}
|
|
|
|
// Get finalized results
|
|
allFixtureIDs := make([]int, len(allFixtures))
|
|
for i, f := range allFixtures {
|
|
allFixtureIDs[i] = f.ID
|
|
}
|
|
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
|
}
|
|
|
|
// Get accepted schedules for ordering recent games
|
|
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
|
}
|
|
|
|
// Compute leaderboard
|
|
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
|
|
|
preview := &MatchPreviewData{
|
|
TotalTeams: len(leaderboard),
|
|
}
|
|
|
|
team1ID := 0
|
|
team2ID := 0
|
|
if series.Team1ID != nil {
|
|
team1ID = *series.Team1ID
|
|
}
|
|
if series.Team2ID != nil {
|
|
team2ID = *series.Team2ID
|
|
}
|
|
|
|
for _, entry := range leaderboard {
|
|
if entry.Team.ID == team1ID {
|
|
preview.HomePosition = entry.Position
|
|
preview.HomeRecord = entry.Record
|
|
}
|
|
if entry.Team.ID == team2ID {
|
|
preview.AwayPosition = entry.Position
|
|
preview.AwayRecord = entry.Record
|
|
}
|
|
}
|
|
if preview.HomeRecord == nil {
|
|
preview.HomeRecord = &TeamRecord{}
|
|
}
|
|
if preview.AwayRecord == nil {
|
|
preview.AwayRecord = &TeamRecord{}
|
|
}
|
|
|
|
// Compute recent games (last 5) for each team
|
|
if team1ID > 0 {
|
|
preview.HomeRecentGames = ComputeRecentGames(
|
|
team1ID, allFixtures, allResultMap, allScheduleMap, 5,
|
|
)
|
|
}
|
|
if team2ID > 0 {
|
|
preview.AwayRecentGames = ComputeRecentGames(
|
|
team2ID, allFixtures, allResultMap, allScheduleMap, 5,
|
|
)
|
|
}
|
|
|
|
return preview, nil
|
|
}
|