Files
oslstats/internal/db/fixture_result.go

364 lines
10 KiB
Go

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
}