472 lines
15 KiB
Go
472 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// Check each player stat: if the player is a registered free agent, mark them
|
|
for _, ps := range playerStats {
|
|
if ps.PlayerID == nil {
|
|
continue
|
|
}
|
|
// Check if the player is a registered free agent
|
|
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 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
|
|
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
|
|
var faWarnings []seasonsview.FreeAgentWarning
|
|
|
|
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
|
|
}
|
|
|
|
// Get nominated free agents for this fixture
|
|
nominatedFAs, err := db.GetNominatedFreeAgents(ctx, tx, fixtureID)
|
|
if err != nil {
|
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
|
|
}
|
|
// Map player ID to the side ("home"/"away") that nominated them
|
|
nominatedFASide := map[int]string{}
|
|
for _, nfa := range nominatedFAs {
|
|
if nfa.TeamID == fixture.HomeTeamID {
|
|
nominatedFASide[nfa.PlayerID] = "home"
|
|
} else {
|
|
nominatedFASide[nfa.PlayerID] = "away"
|
|
}
|
|
}
|
|
|
|
// Helper to resolve side to team name
|
|
teamNameForSide := func(side string) string {
|
|
if side == "home" {
|
|
return fixture.HomeTeam.Name
|
|
}
|
|
return fixture.AwayTeam.Name
|
|
}
|
|
|
|
// Build unmapped players and free agent warnings from stats
|
|
seen := map[int]bool{}
|
|
for _, ps := range result.PlayerStats {
|
|
if ps.PeriodNum != 3 {
|
|
continue
|
|
}
|
|
if ps.PlayerID == nil {
|
|
unmappedPlayers = append(unmappedPlayers,
|
|
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
|
} else if ps.IsFreeAgent && !seen[*ps.PlayerID] {
|
|
seen[*ps.PlayerID] = true
|
|
nominatedSide, wasNominated := nominatedFASide[*ps.PlayerID]
|
|
if !wasNominated {
|
|
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
|
|
Name: ps.PlayerUsername,
|
|
Reason: "not nominated for this fixture",
|
|
})
|
|
} else if nominatedSide != ps.Team {
|
|
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
|
|
Name: ps.PlayerUsername,
|
|
Reason: "nominated by " + teamNameForSide(nominatedSide) + ", but played for " + teamNameForSide(ps.Team),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}); !ok {
|
|
return
|
|
}
|
|
|
|
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), 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)
|
|
})
|
|
}
|