From db2428303700c4d1eabe6572cf48a90a4e39792d Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 15 Mar 2026 19:10:32 +1100 Subject: [PATCH] added finals forfeits --- .../20260316140000_add_series_forfeit.go | 71 +++++++ internal/db/playoff.go | 19 +- internal/db/playoff_results.go | 143 +++++++++++++ internal/handlers/series_forfeit.go | 94 +++++++++ internal/server/routes.go | 6 + .../view/seasonsview/playoff_bracket.templ | 34 +++- internal/view/seasonsview/series_detail.templ | 192 ++++++++++++++++-- 7 files changed, 528 insertions(+), 31 deletions(-) create mode 100644 internal/db/migrations/20260316140000_add_series_forfeit.go create mode 100644 internal/handlers/series_forfeit.go diff --git a/internal/db/migrations/20260316140000_add_series_forfeit.go b/internal/db/migrations/20260316140000_add_series_forfeit.go new file mode 100644 index 0000000..5ce5376 --- /dev/null +++ b/internal/db/migrations/20260316140000_add_series_forfeit.go @@ -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 + }, + ) +} diff --git a/internal/db/playoff.go b/internal/db/playoff.go index cc9965d..9927a96 100644 --- a/internal/db/playoff.go +++ b/internal/db/playoff.go @@ -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") }). diff --git a/internal/db/playoff_results.go b/internal/db/playoff_results.go index 6e47b69..67292a3 100644 --- a/internal/db/playoff_results.go +++ b/internal/db/playoff_results.go @@ -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) diff --git a/internal/handlers/series_forfeit.go b/internal/handlers/series_forfeit.go new file mode 100644 index 0000000..7db8e9a --- /dev/null +++ b/internal/handlers/series_forfeit.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/respond" + "git.haelnorr.com/h/oslstats/internal/throw" + "git.haelnorr.com/h/oslstats/internal/validation" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ForfeitSeries handles POST /series/{series_id}/forfeit +// Forfeits a playoff series. The forfeiting team loses and the opponent wins +// and advances through the bracket. Requires playoffs.manage permission. +func ForfeitSeries( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seriesID, err := strconv.Atoi(r.PathValue("series_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid series ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + forfeitTeamStr := getter.String("forfeit_team").TrimSpace().Required().Value + forfeitReason := getter.String("forfeit_reason").TrimSpace().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + // Validate forfeit_team is "team1" or "team2" + if forfeitTeamStr != "team1" && forfeitTeamStr != "team2" { + notify.Warn(s, w, r, "Invalid Team", "Please select which team is forfeiting.", nil) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetPlayoffSeriesByID")) + return false, nil + } + return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") + } + if series == nil { + respond.NotFound(w, errors.New("series not found")) + return false, nil + } + + // Resolve the forfeit team ID + var forfeitTeamID int + if forfeitTeamStr == "team1" { + if series.Team1ID == nil { + notify.Warn(s, w, r, "No Team", "Team 1 is not assigned.", nil) + return false, nil + } + forfeitTeamID = *series.Team1ID + } else { + if series.Team2ID == nil { + notify.Warn(s, w, r, "No Team", "Team 2 is not assigned.", nil) + return false, nil + } + forfeitTeamID = *series.Team2ID + } + + err = db.ForfeitSeries(ctx, tx, seriesID, forfeitTeamID, forfeitReason, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Forfeit", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.ForfeitSeries") + } + return true, nil + }); !ok { + return + } + + notify.SuccessWithDelay(s, w, r, "Series Forfeited", "The series has been forfeited and the opponent advances.", nil) + respond.HXRedirect(w, "/series/%d", seriesID) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index e76dcb6..e171078 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -417,6 +417,12 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)), }, + // Series forfeit route + { + Path: "/series/{series_id}/forfeit", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.ForfeitSeries(s, conn)), + }, } playerRoutes := []hws.Route{ diff --git a/internal/view/seasonsview/playoff_bracket.templ b/internal/view/seasonsview/playoff_bracket.templ index b451156..1f522bb 100644 --- a/internal/view/seasonsview/playoff_bracket.templ +++ b/internal/view/seasonsview/playoff_bracket.templ @@ -201,6 +201,7 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) {{ hasTeams := series.Team1 != nil || series.Team2 != nil seriesURL := fmt.Sprintf("/series/%d", series.ID) + isForfeit := series.IsForfeit }}
@@ -218,17 +220,24 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) { series.Label } @seriesFormatBadge(series.MatchesToWin)
- @seriesStatusBadge(series.Status) +
+ if isForfeit { + + FF + + } + @seriesStatusBadge(series.Status) +
@seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins, - series.WinnerTeamID, series.MatchesToWin) + series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin) @seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins, - series.WinnerTeamID, series.MatchesToWin) + series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
- if series.MatchesToWin > 1 { + if series.MatchesToWin > 1 && !isForfeit {
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
@@ -236,16 +245,21 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries) } -templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, matchesToWin int) { +templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, forfeitTeamID *int, matchesToWin int) { {{ isWinner := false if team != nil && winnerID != nil { isWinner = team.ID == *winnerID } + isForfeiter := false + if team != nil && forfeitTeamID != nil { + isForfeiter = team.ID == *forfeitTeamID + } isTBD := team == nil }}
+ templ.KV("bg-green/5", isWinner && !isForfeiter), + templ.KV("bg-red/5", isForfeiter) }>
if seed != nil { @@ -260,12 +274,14 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i
@links.TeamLinkInSeason(team, season, league)
- if isWinner { + if isForfeiter { + FF + } else if isWinner { } }
- if matchesToWin > 1 { + if matchesToWin > 1 && forfeitTeamID == nil { diff --git a/internal/view/seasonsview/series_detail.templ b/internal/view/seasonsview/series_detail.templ index e517d19..016e905 100644 --- a/internal/view/seasonsview/series_detail.templ +++ b/internal/view/seasonsview/series_detail.templ @@ -312,30 +312,164 @@ templ seriesUploadPrompt(series *db.PlayoffSeries) { } } }} -
+
if hasPendingMatches {
📋

Results Pending Review

Uploaded results are waiting to be reviewed and finalized.

- - Review Results - +
+ + Review Results + + +
} else {
📋

No Results Uploaded

Upload match log files to record the series results.

- - Upload Match Logs - +
+ + Upload Match Logs + + +
} + @seriesForfeitModal(series) +
+} + +templ seriesForfeitModal(series *db.PlayoffSeries) { + {{ + team1Name := seriesTeamName(series.Team1) + team2Name := seriesTeamName(series.Team2) + }} + } @@ -345,11 +479,21 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) { team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID isBye := series.Status == db.SeriesStatusBye + isForfeit := series.IsForfeit + forfeitTeamName := "" + if isForfeit && series.ForfeitTeam != nil { + forfeitTeamName = series.ForfeitTeam.Name + } }}

Series Score

+ if isForfeit { + + Forfeited + + } @seriesStatusBadge(series.Status) @seriesFormatBadge(series.MatchesToWin)
@@ -386,7 +530,11 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
- if isCompleted { + if isCompleted && isForfeit { + + FORFEIT + + } else if isCompleted { FINAL @@ -412,6 +560,18 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) { }
+ if isForfeit && forfeitTeamName != "" { +
+

+ { forfeitTeamName } forfeited the series +

+ if series.ForfeitReason != nil && *series.ForfeitReason != "" { +

+ { *series.ForfeitReason } +

+ } +
+ } }