added finals forfeits

This commit is contained in:
2026-03-15 19:10:32 +11:00
parent ccc1b0505f
commit db24283037
7 changed files with 528 additions and 31 deletions

View File

@@ -329,6 +329,149 @@ func DeleteSeriesResults(
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)