added log file uploading and match results

This commit is contained in:
2026-02-21 22:25:21 +11:00
parent 2fee14680a
commit 9a143da972
20 changed files with 2595 additions and 61 deletions

View File

@@ -0,0 +1,415 @@
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)
})
}