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" ) const maxUploadSize = 10 << 20 // 10 MB // UploadMatchLogsPage renders the upload form for match log files func UploadMatchLogsPage( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) if err != nil { throw.BadRequest(s, w, r, "Invalid fixture ID", err) return } var fixture *db.Fixture if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error fixture, err = db.GetFixture(ctx, tx, fixtureID) if err != nil { if db.IsBadRequest(err) { throw.NotFound(s, w, r, r.URL.Path) return false, nil } return false, errors.Wrap(err, "db.GetFixture") } // Check if result already exists existing, err := db.GetFixtureResult(ctx, tx, fixtureID) if err != nil { return false, errors.Wrap(err, "db.GetFixtureResult") } if existing != nil { throw.BadRequest(s, w, r, "A result already exists for this fixture. Discard it first to re-upload.", nil) return false, nil } return true, nil }); !ok { return } renderSafely(seasonsview.FixtureUploadResultPage(fixture), s, r, w) }) } // UploadMatchLogs handles POST /fixtures/{fixture_id}/results/upload // Parses 3 multipart files, validates, detects tampering, and stores results. func UploadMatchLogs( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) if err != nil { throw.BadRequest(s, w, r, "Invalid fixture ID", err) return } // Parse multipart form err = r.ParseMultipartForm(maxUploadSize) if err != nil { notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files. Ensure files are under 10MB.", nil) return } // Read the 3 period files periodNames := []string{"period_1", "period_2", "period_3"} logs := make([]*slapshotapi.MatchLog, 3) for i, name := range periodNames { file, _, err := r.FormFile(name) if err != nil { notify.Warn(s, w, r, "Missing File", "All 3 period files are required. Missing: "+name, 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: "+name, nil) return } log, err := slapshotapi.ParseMatchLog(data) if err != nil { notify.Warn(s, w, r, "Parse Error", "Could not parse "+name+": "+err.Error(), nil) return } logs[i] = log } // Detect tampering tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs) if err != nil { notify.Warn(s, w, r, "Validation Error", "Tampering check failed: "+err.Error(), nil) return } var result *db.FixtureResult var unmappedPlayers []string if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { fixture, err := db.GetFixture(ctx, tx, fixtureID) if err != nil { if db.IsBadRequest(err) { respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) return false, nil } return false, errors.Wrap(err, "db.GetFixture") } // Check if result already exists existing, err := db.GetFixtureResult(ctx, tx, fixtureID) if err != nil { return false, errors.Wrap(err, "db.GetFixtureResult") } if existing != nil { notify.Warn(s, w, r, "Result Exists", "A result already exists for this fixture. Discard it first to re-upload.", nil) return false, nil } // Collect all unique game_user_ids across all periods 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 game_user_ids to players playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID) if err != nil { return false, errors.Wrap(err, "MapGameUserIDsToPlayers") } // Determine team orientation using all players from all periods allPlayers := []slapshotapi.Player{} // Use period 3 players for orientation (most complete) allPlayers = append(allPlayers, logs[2].Players...) fixtureHomeIsLogsHome, unmapped, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup) if err != nil { notify.Warn(s, w, r, "Orientation Error", "Could not determine team orientation: "+err.Error()+". Please ensure players have registered Slapshot IDs.", nil) return false, nil } unmappedPlayers = unmapped // Use period 3 (final) data for the result finalLog := logs[2] // Determine winner in fixture terms winner := finalLog.Winner homeScore := finalLog.Score.Home awayScore := finalLog.Score.Away if !fixtureHomeIsLogsHome { // Logs are reversed - swap switch winner { case "home": winner = "away" case "away": winner = "home" } homeScore, awayScore = awayScore, homeScore } // Parse metadata periodsEnabled := finalLog.PeriodsEnabled == "True" customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule) matchLength, _ := strconv.Atoi(finalLog.MatchLength) user := db.CurrentUser(ctx) // Build result var tamperingReasonPtr *string if tamperingDetected { tamperingReasonPtr = &tamperingReason } result = &db.FixtureResult{ FixtureID: fixtureID, 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 for all 3 periods playerStats := []*db.FixtureResultPlayerStats{} for periodIdx, log := range logs { periodNum := periodIdx + 1 for _, p := range log.Players { // Determine team in fixture terms team := p.Team if !fixtureHomeIsLogsHome { if team == "home" { team = "away" } else { team = "home" } } // Look up player 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, // Convert float stats to int 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) } } // Insert result and stats result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "db.InsertFixtureResult") } return true, nil }); !ok { return } _ = unmappedPlayers // stored for review page redirect notify.Success(s, w, r, "Logs Uploaded", "Match logs have been processed. Please review the result.", nil) respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID) }) } // ReviewMatchResult handles GET /fixtures/{fixture_id}/results/review func ReviewMatchResult( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) if err != nil { throw.BadRequest(s, w, r, "Invalid fixture ID", err) return } var fixture *db.Fixture var result *db.FixtureResult var unmappedPlayers []string if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error fixture, err = db.GetFixture(ctx, tx, fixtureID) if err != nil { if db.IsBadRequest(err) { throw.NotFound(s, w, r, r.URL.Path) return false, nil } return false, errors.Wrap(err, "db.GetFixture") } result, err = db.GetPendingFixtureResult(ctx, tx, fixtureID) if err != nil { return false, errors.Wrap(err, "db.GetPendingFixtureResult") } if result == nil { throw.NotFound(s, w, r, r.URL.Path) return false, nil } // Build unmapped players list from stats for _, ps := range result.PlayerStats { if ps.PlayerID == nil && ps.PeriodNum == 3 { unmappedPlayers = append(unmappedPlayers, ps.PlayerGameUserID+" ("+ps.PlayerUsername+")") } } return true, nil }); !ok { return } renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers), s, r, w) }) } // FinalizeMatchResult handles POST /fixtures/{fixture_id}/results/finalize func FinalizeMatchResult( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) if err != nil { throw.BadRequest(s, w, r, "Invalid fixture ID", err) return } if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { err := db.FinalizeFixtureResult(ctx, tx, fixtureID, 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.FinalizeFixtureResult") } return true, nil }); !ok { return } notify.SuccessWithDelay(s, w, r, "Result Finalized", "The match result has been finalized.", nil) respond.HXRedirect(w, "/fixtures/%d", fixtureID) }) } // DiscardMatchResult handles POST /fixtures/{fixture_id}/results/discard func DiscardMatchResult( s *hws.Server, conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) if err != nil { throw.BadRequest(s, w, r, "Invalid fixture ID", err) return } if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { err := db.DeleteFixtureResult(ctx, tx, fixtureID, 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.DeleteFixtureResult") } return true, nil }); !ok { return } notify.Success(s, w, r, "Result Discarded", "The match result has been discarded. You can upload new logs.", nil) respond.HXRedirect(w, "/fixtures/%d", fixtureID) }) }