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 }