364 lines
10 KiB
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
|
|
}
|