537 lines
15 KiB
Go
537 lines
15 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
// AggregatedPlayerStats holds summed stats for a player across multiple fixtures.
|
|
type AggregatedPlayerStats struct {
|
|
PlayerID int `bun:"player_id"`
|
|
PlayerName string `bun:"player_name"`
|
|
GamesPlayed int `bun:"games_played"`
|
|
Score int `bun:"total_score"`
|
|
Goals int `bun:"total_goals"`
|
|
Assists int `bun:"total_assists"`
|
|
Saves int `bun:"total_saves"`
|
|
Shots int `bun:"total_shots"`
|
|
Blocks int `bun:"total_blocks"`
|
|
Passes int `bun:"total_passes"`
|
|
}
|
|
|
|
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
|
|
// players on a given team across all finalized fixture results.
|
|
func GetAggregatedPlayerStatsForTeam(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
teamID int,
|
|
fixtureIDs []int,
|
|
) ([]*AggregatedPlayerStats, error) {
|
|
if len(fixtureIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var stats []*AggregatedPlayerStats
|
|
err := tx.NewRaw(`
|
|
SELECT
|
|
frps.player_id AS player_id,
|
|
COALESCE(p.name, frps.player_username) AS player_name,
|
|
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
|
COALESCE(SUM(frps.score), 0) AS total_score,
|
|
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
|
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
|
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
|
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
|
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
|
COALESCE(SUM(frps.passes), 0) AS total_passes
|
|
FROM fixture_result_player_stats frps
|
|
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
|
LEFT JOIN players p ON p.id = frps.player_id
|
|
WHERE fr.finalized = true
|
|
AND fr.fixture_id IN (?)
|
|
AND frps.team_id = ?
|
|
AND frps.period_num = 3
|
|
AND frps.player_id IS NOT NULL
|
|
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
|
|
ORDER BY total_score DESC
|
|
`, bun.In(fixtureIDs), teamID).Scan(ctx, &stats)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "tx.NewRaw")
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
// TeamRecord holds win/loss/draw record and goal totals for a team.
|
|
type TeamRecord struct {
|
|
Played int
|
|
Wins int
|
|
OvertimeWins int
|
|
OvertimeLosses int
|
|
Losses int
|
|
Draws int
|
|
GoalsFor int
|
|
GoalsAgainst int
|
|
Points int
|
|
}
|
|
|
|
// Point values for the leaderboard scoring system.
|
|
const (
|
|
PointsWin = 3
|
|
PointsOvertimeWin = 2
|
|
PointsOvertimeLoss = 1
|
|
PointsLoss = 0
|
|
)
|
|
|
|
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
|
|
// Points: Win=3, OT Win=2, OT Loss=1, Loss=0.
|
|
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
|
|
rec := &TeamRecord{}
|
|
for _, f := range fixtures {
|
|
res, ok := resultMap[f.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
rec.Played++
|
|
isHome := f.HomeTeamID == teamID
|
|
if isHome {
|
|
rec.GoalsFor += res.HomeScore
|
|
rec.GoalsAgainst += res.AwayScore
|
|
} else {
|
|
rec.GoalsFor += res.AwayScore
|
|
rec.GoalsAgainst += res.HomeScore
|
|
}
|
|
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
|
|
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
|
|
isOT := strings.EqualFold(res.EndReason, "Overtime")
|
|
|
|
switch {
|
|
case won && isOT:
|
|
rec.OvertimeWins++
|
|
rec.Points += PointsOvertimeWin
|
|
case won:
|
|
rec.Wins++
|
|
rec.Points += PointsWin
|
|
case lost && isOT:
|
|
rec.OvertimeLosses++
|
|
rec.Points += PointsOvertimeLoss
|
|
case lost:
|
|
rec.Losses++
|
|
rec.Points += PointsLoss
|
|
default:
|
|
rec.Draws++
|
|
}
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// LeaderboardEntry represents a single team's standing in the league table.
|
|
type LeaderboardEntry struct {
|
|
Position int
|
|
Team *Team
|
|
Record *TeamRecord
|
|
}
|
|
|
|
// ComputeLeaderboard builds a sorted leaderboard from teams, fixtures, and results.
|
|
// Teams are sorted by: Points DESC, Goal Differential DESC, Goals For DESC, Name ASC.
|
|
func ComputeLeaderboard(teams []*Team, fixtures []*Fixture, resultMap map[int]*FixtureResult) []*LeaderboardEntry {
|
|
entries := make([]*LeaderboardEntry, 0, len(teams))
|
|
|
|
// Build a map of team ID -> fixtures involving that team
|
|
teamFixtures := make(map[int][]*Fixture)
|
|
for _, f := range fixtures {
|
|
teamFixtures[f.HomeTeamID] = append(teamFixtures[f.HomeTeamID], f)
|
|
teamFixtures[f.AwayTeamID] = append(teamFixtures[f.AwayTeamID], f)
|
|
}
|
|
|
|
for _, team := range teams {
|
|
record := ComputeTeamRecord(team.ID, teamFixtures[team.ID], resultMap)
|
|
entries = append(entries, &LeaderboardEntry{
|
|
Team: team,
|
|
Record: record,
|
|
})
|
|
}
|
|
|
|
// Sort: Points DESC, then goal diff DESC, then GF DESC, then name ASC
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
ri, rj := entries[i].Record, entries[j].Record
|
|
if ri.Points != rj.Points {
|
|
return ri.Points > rj.Points
|
|
}
|
|
diffI := ri.GoalsFor - ri.GoalsAgainst
|
|
diffJ := rj.GoalsFor - rj.GoalsAgainst
|
|
if diffI != diffJ {
|
|
return diffI > diffJ
|
|
}
|
|
if ri.GoalsFor != rj.GoalsFor {
|
|
return ri.GoalsFor > rj.GoalsFor
|
|
}
|
|
return entries[i].Team.Name < entries[j].Team.Name
|
|
})
|
|
|
|
// Assign positions
|
|
for i := range entries {
|
|
entries[i].Position = i + 1
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
// 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
|
|
}
|