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

@@ -0,0 +1,71 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add is_forfeit column
_, err := conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_team_id column
_, err = conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_team_id INTEGER REFERENCES teams(id)").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add forfeit_reason column
_, err = conn.NewAddColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_reason VARCHAR").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_reason").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("forfeit_team_id").
Exec(ctx)
if err != nil {
return err
}
_, err = conn.NewDropColumn().
Model((*db.PlayoffSeries)(nil)).
ColumnExpr("is_forfeit").
Exec(ctx)
return err
},
)
}

View File

@@ -81,12 +81,18 @@ type PlayoffSeries struct {
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"`
// Forfeit-related fields
IsForfeit bool `bun:"is_forfeit,default:false"`
ForfeitTeamID *int `bun:"forfeit_team_id"` // Which team forfeited
ForfeitReason *string `bun:"forfeit_reason"` // Admin-provided reason
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"`
ForfeitTeam *Team `bun:"rel:belongs-to,join:forfeit_team_id=id"`
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
}
// PlayoffMatch represents a single game within a series
@@ -344,6 +350,7 @@ func GetPlayoffSeriesByID(
Relation("Team2").
Relation("Winner").
Relation("Loser").
Relation("ForfeitTeam").
Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("pm.match_number ASC")
}).

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)