499 lines
13 KiB
Go
499 lines
13 KiB
Go
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
|
|
}
|