package db import ( "context" "time" "github.com/pkg/errors" "github.com/uptrace/bun" ) // playoffFixtureRound generates a unique negative round number for a playoff game fixture. // Format: -(seriesID * 100 + matchNumber) to avoid collision with regular season rounds. func playoffFixtureRound(seriesID, matchNumber int) int { return -(seriesID*100 + matchNumber) } // CreatePlayoffGameFixture creates a Fixture record for a playoff game. // The fixture is linked to the series via a PlayoffMatch record. // team1 is "home", team2 is "away" in fixture terms. func CreatePlayoffGameFixture( ctx context.Context, tx bun.Tx, series *PlayoffSeries, matchNumber int, audit *AuditMeta, ) (*Fixture, *PlayoffMatch, error) { if series == nil || series.Bracket == nil { return nil, nil, errors.New("series and bracket cannot be nil") } if series.Team1ID == nil || series.Team2ID == nil { return nil, nil, BadRequest("both teams must be assigned to create a game fixture") } round := playoffFixtureRound(series.ID, matchNumber) fixture := &Fixture{ SeasonID: series.Bracket.SeasonID, LeagueID: series.Bracket.LeagueID, HomeTeamID: *series.Team1ID, AwayTeamID: *series.Team2ID, Round: round, CreatedAt: time.Now().Unix(), } err := Insert(tx, fixture).WithAudit(audit, &AuditInfo{ Action: "playoff_fixture.create", ResourceType: "fixture", ResourceID: nil, Details: map[string]any{ "series_id": series.ID, "match_number": matchNumber, "home_team_id": *series.Team1ID, "away_team_id": *series.Team2ID, }, }).Exec(ctx) if err != nil { return nil, nil, errors.Wrap(err, "Insert fixture") } // Create or update PlayoffMatch record match := new(PlayoffMatch) err = tx.NewSelect(). Model(match). Where("pm.series_id = ?", series.ID). Where("pm.match_number = ?", matchNumber). Scan(ctx) if err != nil && err.Error() != "sql: no rows in result set" { return nil, nil, errors.Wrap(err, "tx.NewSelect playoff_match") } if match.ID > 0 { // Update existing match with fixture ID match.FixtureID = &fixture.ID match.HomeTeamID = series.Team1ID match.AwayTeamID = series.Team2ID match.Status = "pending" err = UpdateByID(tx, match.ID, match). Column("fixture_id", "home_team_id", "away_team_id", "status"). Exec(ctx) if err != nil { return nil, nil, errors.Wrap(err, "UpdateByID playoff_match") } } else { // Create new match match = &PlayoffMatch{ SeriesID: series.ID, MatchNumber: matchNumber, HomeTeamID: series.Team1ID, AwayTeamID: series.Team2ID, FixtureID: &fixture.ID, Status: "pending", } err = Insert(tx, match).Exec(ctx) if err != nil { return nil, nil, errors.Wrap(err, "Insert playoff_match") } } // Load fixture relations fixture.Season = series.Bracket.Season fixture.League = series.Bracket.League fixture.HomeTeam = series.Team1 fixture.AwayTeam = series.Team2 return fixture, match, nil } // FinalizeSeriesResults finalizes all pending game results for a series, // updates series wins/status, and advances teams as needed. // Returns the number of games finalized. func FinalizeSeriesResults( ctx context.Context, tx bun.Tx, seriesID int, audit *AuditMeta, ) (int, error) { series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) if err != nil { return 0, errors.Wrap(err, "GetPlayoffSeriesByID") } if series == nil { return 0, BadRequest("series not found") } if series.Status == SeriesStatusCompleted { return 0, BadRequest("series is already completed") } // Collect all matches with fixtures that have pending results gamesFinalized := 0 team1Wins := 0 team2Wins := 0 for _, match := range series.Matches { if match.FixtureID == nil { continue } result, err := GetFixtureResult(ctx, tx, *match.FixtureID) if err != nil { return 0, errors.Wrap(err, "GetFixtureResult") } if result == nil { continue } // Finalize the fixture result if pending if !result.Finalized { err = FinalizeFixtureResult(ctx, tx, *match.FixtureID, audit) if err != nil { return 0, errors.Wrap(err, "FinalizeFixtureResult") } gamesFinalized++ } // Update match status now := time.Now().Unix() match.Status = "completed" err = UpdateByID(tx, match.ID, match). Column("status"). Exec(ctx) if err != nil { return 0, errors.Wrap(err, "UpdateByID playoff_match") } _ = now // Count wins: team1 = home, team2 = away in fixture terms if result.Winner == "home" { team1Wins++ } else { team2Wins++ } } if gamesFinalized == 0 { return 0, BadRequest("no pending results to finalize") } // Update series wins series.Team1Wins = team1Wins series.Team2Wins = team2Wins // Determine if series is decided if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin { series.Status = SeriesStatusCompleted if team1Wins >= series.MatchesToWin { series.WinnerTeamID = series.Team1ID series.LoserTeamID = series.Team2ID } else { series.WinnerTeamID = series.Team2ID series.LoserTeamID = series.Team1ID } err = UpdateByID(tx, series.ID, series). Column("team1_wins", "team2_wins", "status", "winner_team_id", "loser_team_id"). WithAudit(audit, &AuditInfo{ Action: "playoff_series.complete", ResourceType: "playoff_series", ResourceID: series.ID, Details: map[string]any{ "team1_wins": team1Wins, "team2_wins": team2Wins, "winner_team_id": series.WinnerTeamID, "loser_team_id": series.LoserTeamID, }, }).Exec(ctx) if err != nil { return 0, errors.Wrap(err, "UpdateByID series complete") } // Advance winner to next series if series.WinnerNextID != nil && series.WinnerNextSlot != nil { err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, *series.WinnerTeamID) if err != nil { return 0, errors.Wrap(err, "advanceTeamToSeries winner") } } // Advance loser to next series (e.g. third place, lower bracket) if series.LoserNextID != nil && series.LoserNextSlot != nil { err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, *series.LoserTeamID) if err != nil { return 0, errors.Wrap(err, "advanceTeamToSeries loser") } } } else { // Series still in progress series.Status = SeriesStatusInProgress err = UpdateByID(tx, series.ID, series). Column("team1_wins", "team2_wins", "status"). Exec(ctx) if err != nil { return 0, errors.Wrap(err, "UpdateByID series in_progress") } } return gamesFinalized, nil } // advanceTeamToSeries places a team into the specified slot of the target series. func advanceTeamToSeries(ctx context.Context, tx bun.Tx, targetSeriesID int, slot string, teamID int) error { switch slot { case "team1": _, err := tx.NewUpdate(). Model((*PlayoffSeries)(nil)). Set("team1_id = ?", teamID). Where("id = ?", targetSeriesID). Exec(ctx) if err != nil { return errors.Wrap(err, "update team1_id") } case "team2": _, err := tx.NewUpdate(). Model((*PlayoffSeries)(nil)). Set("team2_id = ?", teamID). Where("id = ?", targetSeriesID). Exec(ctx) if err != nil { return errors.Wrap(err, "update team2_id") } default: return BadRequest("invalid slot: " + slot) } return nil } // DeleteSeriesResults deletes all pending (non-finalized) fixture results // and their associated fixtures for a series. func DeleteSeriesResults( ctx context.Context, tx bun.Tx, seriesID int, audit *AuditMeta, ) error { series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) if err != nil { return errors.Wrap(err, "GetPlayoffSeriesByID") } if series == nil { return BadRequest("series not found") } for _, match := range series.Matches { if match.FixtureID == nil { continue } result, err := GetFixtureResult(ctx, tx, *match.FixtureID) if err != nil { return errors.Wrap(err, "GetFixtureResult") } if result == nil { continue } if result.Finalized { return BadRequest("cannot discard finalized results") } // Delete the result (CASCADE deletes player stats) err = DeleteFixtureResult(ctx, tx, *match.FixtureID, audit) if err != nil { return errors.Wrap(err, "DeleteFixtureResult") } // Delete the fixture err = DeleteByID[Fixture](tx, *match.FixtureID). WithAudit(audit, &AuditInfo{ Action: "playoff_fixture.delete", ResourceType: "fixture", ResourceID: *match.FixtureID, }).Delete(ctx) if err != nil { return errors.Wrap(err, "DeleteByID fixture") } // Clear fixture ID from match match.FixtureID = nil match.Status = "pending" err = UpdateByID(tx, match.ID, match). Column("fixture_id", "status"). Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID playoff_match") } } return nil } // ForfeitSeries forfeits a playoff series. The forfeiting team loses and the opponent // is declared the winner and advances through the bracket. Any existing match results // and fixtures are discarded. The series score (Team1Wins/Team2Wins) is left as-is. func ForfeitSeries( ctx context.Context, tx bun.Tx, seriesID int, forfeitTeamID int, reason string, audit *AuditMeta, ) error { series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) if err != nil { return errors.Wrap(err, "GetPlayoffSeriesByID") } if series == nil { return BadRequest("series not found") } if series.Status == SeriesStatusCompleted { return BadRequest("series is already completed") } if series.Status == SeriesStatusBye { return BadRequest("cannot forfeit a bye series") } if series.Team1ID == nil || series.Team2ID == nil { return BadRequest("both teams must be assigned to forfeit a series") } // Validate forfeit team is one of the teams in the series if forfeitTeamID != *series.Team1ID && forfeitTeamID != *series.Team2ID { return BadRequest("forfeit team must be one of the teams in the series") } // Determine winner and loser var winnerTeamID int if forfeitTeamID == *series.Team1ID { winnerTeamID = *series.Team2ID } else { winnerTeamID = *series.Team1ID } // Discard all existing match results and fixtures for _, match := range series.Matches { if match.FixtureID == nil { continue } result, err := GetFixtureResult(ctx, tx, *match.FixtureID) if err != nil { return errors.Wrap(err, "GetFixtureResult") } if result != nil { // Delete result (CASCADE deletes player stats) err = DeleteByID[FixtureResult](tx, result.ID). WithAudit(audit, &AuditInfo{ Action: "fixture_results.discard_for_forfeit", ResourceType: "fixture_result", ResourceID: result.ID, Details: map[string]any{ "fixture_id": *match.FixtureID, "series_id": seriesID, }, }).Delete(ctx) if err != nil { return errors.Wrap(err, "DeleteByID fixture_result") } } // Delete the fixture err = DeleteByID[Fixture](tx, *match.FixtureID). WithAudit(audit, &AuditInfo{ Action: "playoff_fixture.delete_for_forfeit", ResourceType: "fixture", ResourceID: *match.FixtureID, Details: map[string]any{ "series_id": seriesID, }, }).Delete(ctx) if err != nil { return errors.Wrap(err, "DeleteByID fixture") } // Clear fixture ID from match match.FixtureID = nil match.Status = "pending" err = UpdateByID(tx, match.ID, match). Column("fixture_id", "status"). Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID playoff_match") } } // Update series with forfeit info var reasonPtr *string if reason != "" { reasonPtr = &reason } series.Status = SeriesStatusCompleted series.IsForfeit = true series.ForfeitTeamID = &forfeitTeamID series.ForfeitReason = reasonPtr series.WinnerTeamID = &winnerTeamID series.LoserTeamID = &forfeitTeamID err = UpdateByID(tx, series.ID, series). Column("status", "is_forfeit", "forfeit_team_id", "forfeit_reason", "winner_team_id", "loser_team_id"). WithAudit(audit, &AuditInfo{ Action: "playoff_series.forfeit", ResourceType: "playoff_series", ResourceID: series.ID, Details: map[string]any{ "forfeit_team_id": forfeitTeamID, "winner_team_id": winnerTeamID, "reason": reason, }, }).Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID series forfeit") } // Advance winner to next series if series.WinnerNextID != nil && series.WinnerNextSlot != nil { err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, winnerTeamID) if err != nil { return errors.Wrap(err, "advanceTeamToSeries winner") } } // Advance loser to next series (e.g. lower bracket) if series.LoserNextID != nil && series.LoserNextSlot != nil { err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, forfeitTeamID) if err != nil { return errors.Wrap(err, "advanceTeamToSeries loser") } } return nil } // HasPendingSeriesResults checks if a series has any pending (non-finalized) results. func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) { series, err := GetPlayoffSeriesByID(ctx, tx, seriesID) if err != nil { return false, errors.Wrap(err, "GetPlayoffSeriesByID") } if series == nil { return false, nil } for _, match := range series.Matches { if match.FixtureID == nil { continue } result, err := GetPendingFixtureResult(ctx, tx, *match.FixtureID) if err != nil { return false, errors.Wrap(err, "GetPendingFixtureResult") } if result != nil { return true, nil } } return false, nil }