added log file uploading and match results
This commit is contained in:
363
internal/db/fixture_result.go
Normal file
363
internal/db/fixture_result.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type FixtureResult struct {
|
||||
bun.BaseModel `bun:"table:fixture_results,alias:fr"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
FixtureID int `bun:",notnull,unique"`
|
||||
Winner string `bun:",notnull"`
|
||||
HomeScore int `bun:",notnull"`
|
||||
AwayScore int `bun:",notnull"`
|
||||
MatchType string
|
||||
Arena string
|
||||
EndReason string
|
||||
PeriodsEnabled bool
|
||||
CustomMercyRule int
|
||||
MatchLength int
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
UpdatedAt *int64
|
||||
UploadedByUserID int `bun:",notnull"`
|
||||
Finalized bool `bun:",default:false"`
|
||||
TamperingDetected bool `bun:",default:false"`
|
||||
TamperingReason *string
|
||||
|
||||
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
|
||||
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
|
||||
}
|
||||
|
||||
type FixtureResultPlayerStats struct {
|
||||
bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
FixtureResultID int `bun:",notnull"`
|
||||
PeriodNum int `bun:",notnull"`
|
||||
PlayerID *int // NULL for unmapped/free agents
|
||||
PlayerGameUserID string `bun:",notnull"`
|
||||
PlayerUsername string `bun:",notnull"`
|
||||
TeamID *int // NULL for unmapped
|
||||
Team string `bun:",notnull"` // 'home' or 'away'
|
||||
|
||||
// All stats as INT (nullable)
|
||||
Goals *int
|
||||
Assists *int
|
||||
PrimaryAssists *int
|
||||
SecondaryAssists *int
|
||||
Saves *int
|
||||
Blocks *int
|
||||
Shots *int
|
||||
Turnovers *int
|
||||
Takeaways *int
|
||||
Passes *int
|
||||
PossessionTimeSec *int
|
||||
FaceoffsWon *int
|
||||
FaceoffsLost *int
|
||||
PostHits *int
|
||||
OvertimeGoals *int
|
||||
GameWinningGoals *int
|
||||
Score *int
|
||||
ContributedGoals *int
|
||||
ConcededGoals *int
|
||||
GamesPlayed *int
|
||||
Wins *int
|
||||
Losses *int
|
||||
OvertimeWins *int
|
||||
OvertimeLosses *int
|
||||
Ties *int
|
||||
Shutouts *int
|
||||
ShutoutsAgainst *int
|
||||
HasMercyRuled *int
|
||||
WasMercyRuled *int
|
||||
PeriodsPlayed *int
|
||||
|
||||
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
|
||||
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||
TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"`
|
||||
}
|
||||
|
||||
// PlayerWithPlayStatus is a helper struct for overview display
|
||||
type PlayerWithPlayStatus struct {
|
||||
Player *Player
|
||||
Played bool
|
||||
IsManager bool
|
||||
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
|
||||
}
|
||||
|
||||
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
|
||||
func InsertFixtureResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
result *FixtureResult,
|
||||
playerStats []*FixtureResultPlayerStats,
|
||||
audit *AuditMeta,
|
||||
) (*FixtureResult, error) {
|
||||
if result == nil {
|
||||
return nil, errors.New("result cannot be nil")
|
||||
}
|
||||
|
||||
result.CreatedAt = time.Now().Unix()
|
||||
|
||||
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.create",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: nil,
|
||||
Details: map[string]any{
|
||||
"fixture_id": result.FixtureID,
|
||||
"winner": result.Winner,
|
||||
"home_score": result.HomeScore,
|
||||
"away_score": result.AwayScore,
|
||||
"tampering_detected": result.TamperingDetected,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert result")
|
||||
}
|
||||
|
||||
// Set the fixture_result_id on all player stats
|
||||
for _, ps := range playerStats {
|
||||
ps.FixtureResultID = result.ID
|
||||
}
|
||||
|
||||
// Insert player stats in bulk
|
||||
if len(playerStats) > 0 {
|
||||
err = InsertMultiple(tx, playerStats).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "InsertMultiple player stats")
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFixtureResult retrieves a result with all player stats for a fixture.
|
||||
// Returns nil, nil if no result exists.
|
||||
func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
|
||||
result := new(FixtureResult)
|
||||
err := tx.NewSelect().
|
||||
Model(result).
|
||||
Where("fr.fixture_id = ?", fixtureID).
|
||||
Relation("Fixture").
|
||||
Relation("UploadedBy").
|
||||
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
|
||||
}).
|
||||
Relation("PlayerStats.Player").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPendingFixtureResult retrieves a non-finalized result for review/edit.
|
||||
// Returns nil, nil if no pending result exists.
|
||||
func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
|
||||
result := new(FixtureResult)
|
||||
err := tx.NewSelect().
|
||||
Model(result).
|
||||
Where("fr.fixture_id = ?", fixtureID).
|
||||
Where("fr.finalized = false").
|
||||
Relation("Fixture").
|
||||
Relation("UploadedBy").
|
||||
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
|
||||
}).
|
||||
Relation("PlayerStats.Player").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FinalizeFixtureResult marks a pending result as finalized.
|
||||
func FinalizeFixtureResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
return BadRequest("no pending result to finalize")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
result.Finalized = true
|
||||
result.UpdatedAt = &now
|
||||
|
||||
err = UpdateByID(tx, result.ID, result).
|
||||
Column("finalized", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.finalize",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: result.ID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFixtureResult removes a pending result and all associated player stats (CASCADE).
|
||||
func DeleteFixtureResult(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
return BadRequest("no pending result to discard")
|
||||
}
|
||||
|
||||
err = DeleteByID[FixtureResult](tx, result.ID).
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.discard",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: result.ID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
},
|
||||
}).Delete(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "DeleteByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs.
|
||||
// Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency).
|
||||
func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) {
|
||||
if len(fixtureIDs) == 0 {
|
||||
return map[int]*FixtureResult{}, nil
|
||||
}
|
||||
results, err := GetList[FixtureResult](tx).
|
||||
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
|
||||
Where("finalized = true").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetList")
|
||||
}
|
||||
resultMap := make(map[int]*FixtureResult, len(results))
|
||||
for _, r := range results {
|
||||
resultMap[r.FixtureID] = r
|
||||
}
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
// GetFixtureTeamRosters returns all team players with participation status for a fixture.
|
||||
// Returns: map["home"|"away"] -> []*PlayerWithPlayStatus
|
||||
func GetFixtureTeamRosters(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *Fixture,
|
||||
result *FixtureResult,
|
||||
) (map[string][]*PlayerWithPlayStatus, error) {
|
||||
if fixture == nil {
|
||||
return nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
rosters := map[string][]*PlayerWithPlayStatus{}
|
||||
|
||||
// Get home team roster
|
||||
homeRosters := []*TeamRoster{}
|
||||
err := tx.NewSelect().
|
||||
Model(&homeRosters).
|
||||
Where("tr.team_id = ?", fixture.HomeTeamID).
|
||||
Where("tr.season_id = ?", fixture.SeasonID).
|
||||
Where("tr.league_id = ?", fixture.LeagueID).
|
||||
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Relation("User")
|
||||
}).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect home roster")
|
||||
}
|
||||
|
||||
// Get away team roster
|
||||
awayRosters := []*TeamRoster{}
|
||||
err = tx.NewSelect().
|
||||
Model(&awayRosters).
|
||||
Where("tr.team_id = ?", fixture.AwayTeamID).
|
||||
Where("tr.season_id = ?", fixture.SeasonID).
|
||||
Where("tr.league_id = ?", fixture.LeagueID).
|
||||
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Relation("User")
|
||||
}).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect away roster")
|
||||
}
|
||||
|
||||
// Build maps of player IDs that played and their period 3 stats
|
||||
playedPlayerIDs := map[int]bool{}
|
||||
playerStatsByID := map[int]*FixtureResultPlayerStats{}
|
||||
if result != nil {
|
||||
for _, ps := range result.PlayerStats {
|
||||
if ps.PlayerID != nil {
|
||||
playedPlayerIDs[*ps.PlayerID] = true
|
||||
if ps.PeriodNum == 3 {
|
||||
playerStatsByID[*ps.PlayerID] = ps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build home roster with play status and stats
|
||||
for _, r := range homeRosters {
|
||||
played := false
|
||||
var stats *FixtureResultPlayerStats
|
||||
if result != nil && r.Player != nil {
|
||||
played = playedPlayerIDs[r.Player.ID]
|
||||
stats = playerStatsByID[r.Player.ID]
|
||||
}
|
||||
rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{
|
||||
Player: r.Player,
|
||||
Played: played,
|
||||
IsManager: r.IsManager,
|
||||
Stats: stats,
|
||||
})
|
||||
}
|
||||
|
||||
// Build away roster with play status and stats
|
||||
for _, r := range awayRosters {
|
||||
played := false
|
||||
var stats *FixtureResultPlayerStats
|
||||
if result != nil && r.Player != nil {
|
||||
played = playedPlayerIDs[r.Player.ID]
|
||||
stats = playerStatsByID[r.Player.ID]
|
||||
}
|
||||
rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{
|
||||
Player: r.Player,
|
||||
Played: played,
|
||||
IsManager: r.IsManager,
|
||||
Stats: stats,
|
||||
})
|
||||
}
|
||||
|
||||
return rosters, nil
|
||||
}
|
||||
Reference in New Issue
Block a user