package db import ( "context" "fmt" "strconv" "strings" "time" "github.com/pkg/errors" "github.com/uptrace/bun" ) type Fixture struct { bun.BaseModel `bun:"table:fixtures,alias:f"` ID int `bun:"id,pk,autoincrement"` SeasonID int `bun:",notnull,unique:round"` LeagueID int `bun:",notnull,unique:round"` HomeTeamID int `bun:",notnull,unique:round"` AwayTeamID int `bun:",notnull,unique:round"` Round int `bun:"round,unique:round"` GameWeek *int `bun:"game_week"` CreatedAt int64 `bun:"created_at,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"` HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"` AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"` Schedules []*FixtureSchedule `bun:"rel:has-many,join:id=fixture_id"` } // CanSchedule checks if the user is a manager of one of the teams in the fixture. // Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager). func (f *Fixture) CanSchedule(ctx context.Context, tx bun.Tx, user *User) (bool, int, error) { if user == nil || user.Player == 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{f.HomeTeamID, f.AwayTeamID})). Where("season_id = ?", f.SeasonID). Where("league_id = ?", f.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 } func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, homeTeamID, awayTeamID, round int, audit *AuditMeta, ) (*Fixture, error) { season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName) if err != nil { return nil, errors.Wrap(err, "GetSeasonLeague") } homeTeam, err := GetTeam(ctx, tx, homeTeamID) if err != nil { return nil, errors.Wrap(err, "GetTeam") } awayTeam, err := GetTeam(ctx, tx, awayTeamID) if err != nil { return nil, errors.Wrap(err, "GetTeam") } if err = checkTeamsAssociated(season, league, teams, []*Team{homeTeam, awayTeam}); err != nil { return nil, errors.Wrap(err, "checkTeamsAssociated") } fixture := newFixture(season, league, homeTeam, awayTeam, round, time.Now()) err = Insert(tx, fixture).WithAudit(audit, nil).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "Insert") } return fixture, nil } func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, round int, audit *AuditMeta, ) ([]*Fixture, error) { season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName) if err != nil { return nil, errors.Wrap(err, "GetSeasonLeague") } fixtures := generateRound(season, league, round, teams) err = InsertMultiple(tx, fixtures).WithAudit(audit, nil).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "InsertMultiple") } return fixtures, nil } func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) { sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) if err != nil { return nil, nil, errors.Wrap(err, "GetSeasonLeague") } fixtures, err := GetList[Fixture](tx). Where("season_id = ?", sl.SeasonID). Where("league_id = ?", sl.LeagueID). Order("game_week ASC NULLS FIRST", "round ASC", "id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { return nil, nil, errors.Wrap(err, "GetList") } return sl, fixtures, nil } func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) { return GetByID[Fixture](tx, id). Relation("Season"). Relation("League"). Relation("HomeTeam"). Relation("AwayTeam"). Get(ctx) } // GetAllocatedFixtures returns all fixtures with a game_week assigned for a season+league. func GetAllocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). Where("league_id = ?", leagueID). Where("game_week IS NOT NULL"). Order("game_week ASC", "round ASC", "id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } return fixtures, nil } func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). Where("league_id = ?", leagueID). Where("game_week IS NOT NULL"). Where("(home_team_id = ? OR away_team_id = ?)", teamID, teamID). Order("game_week ASC", "round ASC", "id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } return fixtures, nil } func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). Where("league_id = ?", leagueID). Where("game_week = ?", gameweek). Order("round ASC", "id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } return fixtures, nil } func GetUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). Where("league_id = ?", leagueID). Where("game_week IS NULL"). Order("round ASC", "id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } return fixtures, nil } func CountUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) { count, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). Where("league_id = ?", leagueID). Where("game_week IS NULL"). Count(ctx) if err != nil { return 0, errors.Wrap(err, "GetList") } return count, nil } func GetMaxGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) { var maxGameWeek int err := tx.NewSelect(). Model((*Fixture)(nil)). Column("game_week"). Where("season_id = ?", seasonID). Where("league_id = ?", leagueID). Order("game_week DESC NULLS LAST"). Limit(1).Scan(ctx, &maxGameWeek) if err != nil { return 0, errors.Wrap(err, "tx.NewSelect") } return maxGameWeek, nil } func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture, audit *AuditMeta) error { details := []any{} for _, fixture := range fixtures { err := UpdateByID(tx, fixture.ID, fixture). Column("game_week"). Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID") } details = append(details, map[string]any{"fixture_id": fixture.ID, "game_week": fixture.GameWeek}) } info := &AuditInfo{ "fixtures.manage", "fixture", "multiple", map[string]any{"updated": details}, } err := LogSuccess(ctx, tx, audit, info) if err != nil { return errors.Wrap(err, "LogSuccess") } return nil } func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error { sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) if err != nil { return errors.Wrap(err, "GetSeasonLeague") } err = DeleteItem[Fixture](tx). Where("season_id = ?", sl.SeasonID). Where("league_id = ?", sl.LeagueID). WithAudit(audit, nil). Delete(ctx) if err != nil { return errors.Wrap(err, "DeleteItem") } return nil } func DeleteFixture(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error { err := DeleteByID[Fixture](tx, id). WithAudit(audit, nil). Delete(ctx) if err != nil { return errors.Wrap(err, "DeleteByID") } return nil } func newFixture(season *Season, league *League, homeTeam, awayTeam *Team, round int, created time.Time) *Fixture { return &Fixture{ SeasonID: season.ID, LeagueID: league.ID, HomeTeamID: homeTeam.ID, AwayTeamID: awayTeam.ID, Round: round, CreatedAt: created.Unix(), } } func checkTeamsAssociated(season *Season, league *League, teamsIn []*Team, toCheck []*Team) error { badIDs := []string{} master := map[int]bool{} for _, team := range teamsIn { master[team.ID] = true } for _, team := range toCheck { if !master[team.ID] { badIDs = append(badIDs, strconv.Itoa(team.ID)) } } ids := strings.Join(badIDs, ",") if len(ids) > 0 { return BadRequestNotAssociated("season_league", "team", "season_id,league_id", "ids", fmt.Sprintf("%v,%v", season.ID, league.ID), ids) } return nil } type versus struct { homeTeam *Team awayTeam *Team } func generateRound(season *Season, league *League, round int, teams []*Team) []*Fixture { now := time.Now() numTeams := len(teams) numGames := numTeams * (numTeams - 1) / 2 fixtures := make([]*Fixture, numGames) for i, matchup := range allTeamsPlay(teams, round) { fixtures[i] = newFixture(season, league, matchup.homeTeam, matchup.awayTeam, round, now) } return fixtures } func allTeamsPlay(teams []*Team, round int) []*versus { matchups := []*versus{} if len(teams) < 2 { return matchups } team1 := teams[0] teams = teams[1:] matchups = append(matchups, playOtherTeams(team1, teams, round)...) matchups = append(matchups, allTeamsPlay(teams, round)...) return matchups } func playOtherTeams(team *Team, teams []*Team, round int) []*versus { matchups := make([]*versus, len(teams)) for i, opponent := range teams { versus := &versus{} if (i+round)%2 == 0 { versus.homeTeam = team versus.awayTeam = opponent } else { versus.homeTeam = opponent versus.awayTeam = team } matchups[i] = versus } return matchups } func AutoAllocateFixtures(fixtures []*Fixture, gamesPerWeek, startingWeek int) []*Fixture { gameWeek := startingWeek teamPlays := map[int]int{} // Work on a copy so we can track what's remaining remaining := make([]*Fixture, len(fixtures)) copy(remaining, fixtures) for len(remaining) > 0 { madeProgress := false nextRemaining := make([]*Fixture, 0, len(remaining)) for _, fixture := range remaining { if teamPlays[fixture.HomeTeamID] < gamesPerWeek && teamPlays[fixture.AwayTeamID] < gamesPerWeek { gw := gameWeek fixture.GameWeek = &gw teamPlays[fixture.HomeTeamID]++ teamPlays[fixture.AwayTeamID]++ madeProgress = true } else { nextRemaining = append(nextRemaining, fixture) } } if !madeProgress { // No fixture could be placed this week — advance to avoid infinite loop // (shouldn't happen with valid fixture data, but guards against edge cases) gameWeek++ teamPlays = map[int]int{} continue } remaining = nextRemaining if len(remaining) > 0 { gameWeek++ teamPlays = map[int]int{} } } return fixtures }