package handlers import ( "context" "io" "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/view/seasonsview" "git.haelnorr.com/h/oslstats/pkg/slapshotapi" "github.com/pkg/errors" "github.com/uptrace/bun" ) // SeriesUploadResultPage renders the upload form for series match logs func SeriesUploadResultPage( 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 } var series *db.PlayoffSeries if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) if err != nil { return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") } if series == nil { throw.NotFound(s, w, r, r.URL.Path) return false, nil } // Check for existing pending results hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID) if err != nil { return false, errors.Wrap(err, "db.HasPendingSeriesResults") } if hasPending { throw.BadRequest(s, w, r, "Pending results already exist for this series. Discard them first to re-upload.", nil) return false, nil } return true, nil }); !ok { return } renderSafely(seasonsview.SeriesUploadResultPage(series), s, r, w) }) } // SeriesUploadResults handles POST /series/{series_id}/results/upload // Parses match logs for all games, creates fixtures + results. func SeriesUploadResults( 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 } // Parse multipart form err = r.ParseMultipartForm(maxUploadSize * 5) // up to 5 games worth if err != nil { notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files.", nil) return } gameCountStr := r.FormValue("game_count") gameCount, err := strconv.Atoi(gameCountStr) if err != nil || gameCount < 1 { notify.Warn(s, w, r, "Invalid Input", "Please select a valid number of games.", nil) return } // Parse all game logs type gameLogs struct { Logs [3]*slapshotapi.MatchLog } allGameLogs := make([]*gameLogs, gameCount) for g := 1; g <= gameCount; g++ { gl := &gameLogs{} for p := 1; p <= 3; p++ { fieldName := "game_" + strconv.Itoa(g) + "_period_" + strconv.Itoa(p) file, _, err := r.FormFile(fieldName) if err != nil { notify.Warn(s, w, r, "Missing File", "All 3 period files are required for Game "+strconv.Itoa(g)+". Missing period "+strconv.Itoa(p)+".", nil) return } defer func() { _ = file.Close() }() data, err := io.ReadAll(file) if err != nil { notify.Warn(s, w, r, "Read Error", "Could not read file: "+fieldName, nil) return } log, err := slapshotapi.ParseMatchLog(data) if err != nil { notify.Warn(s, w, r, "Parse Error", "Could not parse Game "+strconv.Itoa(g)+" Period "+strconv.Itoa(p)+": "+err.Error(), nil) return } gl.Logs[p-1] = log } allGameLogs[g-1] = gl } 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 { return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") } if series == nil { respond.NotFound(w, errors.New("series not found")) return false, nil } // Validate game count maxGames := series.MatchesToWin*2 - 1 if gameCount < series.MatchesToWin || gameCount > maxGames { notify.Warn(s, w, r, "Invalid Game Count", "Game count must be between "+strconv.Itoa(series.MatchesToWin)+" and "+strconv.Itoa(maxGames)+".", nil) return false, nil } // Check for existing pending results hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID) if err != nil { return false, errors.Wrap(err, "db.HasPendingSeriesResults") } if hasPending { notify.Warn(s, w, r, "Results Exist", "Pending results already exist. Discard them first.", nil) return false, nil } audit := db.NewAuditFromRequest(r) user := db.CurrentUser(ctx) // Process each game team1Wins := 0 team2Wins := 0 for g := 0; g < gameCount; g++ { gl := allGameLogs[g] logs := []*slapshotapi.MatchLog{gl.Logs[0], gl.Logs[1], gl.Logs[2]} matchNumber := g + 1 // Check if series is already decided if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin { notify.Warn(s, w, r, "Too Many Games", "The series was already decided before Game "+strconv.Itoa(matchNumber)+". Reduce the game count.", nil) return false, nil } // Detect tampering tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs) if err != nil { notify.Warn(s, w, r, "Validation Error", "Game "+strconv.Itoa(matchNumber)+" tampering check failed: "+err.Error(), nil) return false, nil } // Create fixture for this game fixture, _, err := db.CreatePlayoffGameFixture(ctx, tx, series, matchNumber, audit) if err != nil { return false, errors.Wrap(err, "db.CreatePlayoffGameFixture") } // Collect game_user_ids gameUserIDSet := map[string]bool{} for _, log := range logs { for _, p := range log.Players { gameUserIDSet[p.GameUserID] = true } } gameUserIDs := make([]string, 0, len(gameUserIDSet)) for id := range gameUserIDSet { gameUserIDs = append(gameUserIDs, id) } // Map players playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID) if err != nil { return false, errors.Wrap(err, "MapGameUserIDsToPlayers") } // Determine orientation allPlayers := logs[2].Players fixtureHomeIsLogsHome, _, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup) if err != nil { notify.Warn(s, w, r, "Orientation Error", "Game "+strconv.Itoa(matchNumber)+": Could not determine team orientation: "+err.Error(), nil) return false, nil } // Build result finalLog := logs[2] winner := finalLog.Winner homeScore := finalLog.Score.Home awayScore := finalLog.Score.Away if !fixtureHomeIsLogsHome { switch winner { case "home": winner = "away" case "away": winner = "home" } homeScore, awayScore = awayScore, homeScore } periodsEnabled := finalLog.PeriodsEnabled == "True" customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule) matchLength, _ := strconv.Atoi(finalLog.MatchLength) var tamperingReasonPtr *string if tamperingDetected { tamperingReasonPtr = &tamperingReason } result := &db.FixtureResult{ FixtureID: fixture.ID, Winner: winner, HomeScore: homeScore, AwayScore: awayScore, MatchType: finalLog.Type, Arena: finalLog.Arena, EndReason: finalLog.EndReason, PeriodsEnabled: periodsEnabled, CustomMercyRule: customMercyRule, MatchLength: matchLength, UploadedByUserID: user.ID, Finalized: false, TamperingDetected: tamperingDetected, TamperingReason: tamperingReasonPtr, } // Build player stats playerStats := []*db.FixtureResultPlayerStats{} for periodIdx, log := range logs { periodNum := periodIdx + 1 for _, p := range log.Players { team := p.Team if !fixtureHomeIsLogsHome { if team == "home" { team = "away" } else { team = "home" } } var playerID *int var teamID *int if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found { playerID = &lookup.Player.ID if !lookup.Unmapped { teamID = &lookup.TeamID } } stat := &db.FixtureResultPlayerStats{ PeriodNum: periodNum, PlayerID: playerID, PlayerGameUserID: p.GameUserID, PlayerUsername: p.Username, TeamID: teamID, Team: team, Goals: FloatToIntPtr(p.Stats.Goals), Assists: FloatToIntPtr(p.Stats.Assists), PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists), SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists), Saves: FloatToIntPtr(p.Stats.Saves), Blocks: FloatToIntPtr(p.Stats.Blocks), Shots: FloatToIntPtr(p.Stats.Shots), Turnovers: FloatToIntPtr(p.Stats.Turnovers), Takeaways: FloatToIntPtr(p.Stats.Takeaways), Passes: FloatToIntPtr(p.Stats.Passes), PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime), FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon), FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost), PostHits: FloatToIntPtr(p.Stats.PostHits), OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals), GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals), Score: FloatToIntPtr(p.Stats.Score), ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals), ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals), GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed), Wins: FloatToIntPtr(p.Stats.Wins), Losses: FloatToIntPtr(p.Stats.Losses), OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins), OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses), Ties: FloatToIntPtr(p.Stats.Ties), Shutouts: FloatToIntPtr(p.Stats.Shutouts), ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst), HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled), WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled), PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed), } playerStats = append(playerStats, stat) } } // Mark free agents for _, ps := range playerStats { if ps.PlayerID == nil { continue } isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID) if err != nil { return false, errors.Wrap(err, "db.IsFreeAgentRegistered") } if isFA { ps.IsFreeAgent = true } } // Insert result _, err = db.InsertFixtureResult(ctx, tx, result, playerStats, audit) if err != nil { return false, errors.Wrap(err, "db.InsertFixtureResult") } // Track wins: home = team1, away = team2 if winner == "home" { team1Wins++ } else { team2Wins++ } } // Validate that the series result is valid if team1Wins < series.MatchesToWin && team2Wins < series.MatchesToWin { notify.Warn(s, w, r, "Incomplete Series", "Neither team has enough wins to decide the series. More games are needed.", nil) return false, nil } return true, nil }); !ok { return } respond.HXRedirect(w, "/series/%d/results/review", seriesID) }) } // SeriesReviewResults handles GET /series/{series_id}/results/review func SeriesReviewResults( 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 } var series *db.PlayoffSeries var gameResults []*seasonsview.SeriesGameResult if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID) if err != nil { return false, errors.Wrap(err, "db.GetPlayoffSeriesByID") } if series == nil { throw.NotFound(s, w, r, r.URL.Path) return false, nil } // Build game results from matches for _, match := range series.Matches { if match.FixtureID == nil { continue } result, err := db.GetPendingFixtureResult(ctx, tx, *match.FixtureID) if err != nil { return false, errors.Wrap(err, "db.GetPendingFixtureResult") } if result == nil { continue } gr := &seasonsview.SeriesGameResult{ GameNumber: match.MatchNumber, Result: result, } // Build unmapped players and FA warnings for _, ps := range result.PlayerStats { if ps.PeriodNum != 3 { continue } if ps.PlayerID == nil { gr.UnmappedPlayers = append(gr.UnmappedPlayers, ps.PlayerGameUserID+" ("+ps.PlayerUsername+")") } else if ps.IsFreeAgent { gr.FreeAgentWarnings = append(gr.FreeAgentWarnings, seasonsview.FreeAgentWarning{ Name: ps.PlayerUsername, Reason: "free agent in playoff match", }) } } gameResults = append(gameResults, gr) } if len(gameResults) == 0 { throw.NotFound(s, w, r, r.URL.Path) return false, nil } return true, nil }); !ok { return } renderSafely(seasonsview.SeriesReviewResultPage(series, gameResults), s, r, w) }) } // SeriesFinalizeResults handles POST /series/{series_id}/results/finalize func SeriesFinalizeResults( 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 } if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { _, err := db.FinalizeSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil) return false, nil } return false, errors.Wrap(err, "db.FinalizeSeriesResults") } return true, nil }); !ok { return } notify.SuccessWithDelay(s, w, r, "Series Finalized", "All game results have been finalized and the series is complete.", nil) respond.HXRedirect(w, "/series/%d", seriesID) }) } // SeriesDiscardResults handles POST /series/{series_id}/results/discard func SeriesDiscardResults( 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 } if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { err := db.DeleteSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil) return false, nil } return false, errors.Wrap(err, "db.DeleteSeriesResults") } return true, nil }); !ok { return } notify.Success(s, w, r, "Results Discarded", "All uploaded results have been discarded. You can upload new logs.", nil) respond.HXRedirect(w, "/series/%d", seriesID) }) }