package db import ( "context" "github.com/pkg/errors" "github.com/uptrace/bun" ) type Player struct { bun.BaseModel `bun:"table:players,alias:p"` ID int `bun:"id,pk,autoincrement" json:"id"` SlapID *uint32 `bun:"slap_id,unique" json:"slap_id"` DiscordID string `bun:"discord_id,unique,notnull" json:"discord_id"` UserID *int `bun:"user_id,unique" json:"user_id"` Name string `bun:"name,notnull" json:"name"` User *User `bun:"rel:belongs-to,join:user_id=id" json:"-"` } func (p *Player) DisplayName() string { if p.User != nil { return p.User.Username } return p.Name } // NewPlayer creates a new player in the database. If there is an existing user with the same // discordID, it will automatically link that user to the player func NewPlayer(ctx context.Context, tx bun.Tx, name, discordID string, audit *AuditMeta) (*Player, error) { player := &Player{DiscordID: discordID, Name: name} user, err := GetUserByDiscordID(ctx, tx, discordID) if err != nil && !IsBadRequest(err) { return nil, errors.Wrap(err, "GetUserByDiscordID") } if user != nil { player.UserID = &user.ID player.Name = user.Username } err = Insert(tx, player). WithAudit(audit, nil).Exec(ctx) if err != nil { return nil, errors.Wrap(err, "Insert") } return player, nil } // ConnectPlayer links the user to an existing player, or creates a new player to link if not found // Populates User.Player on success func (u *User) ConnectPlayer(ctx context.Context, tx bun.Tx, audit *AuditMeta) error { player, err := GetByField[Player](tx, "p.discord_id", u.DiscordID). Relation("User").Get(ctx) if err != nil { if !IsBadRequest(err) { // Unexpected error occured return errors.Wrap(err, "GetByField") } // Player doesn't exist, create a new one player, err = NewPlayer(ctx, tx, u.Username, u.DiscordID, audit) if err != nil { return errors.Wrap(err, "NewPlayer") } // New player should automatically get linked to the user u.Player = player return nil } // Player was found if player.UserID != nil { if player.UserID == &u.ID { return nil } return errors.New("player with that discord_id already linked to a user") } player.UserID = &u.ID err = UpdateByID(tx, player.ID, player).Column("user_id").Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID") } u.Player = player return nil } func GetPlayer(ctx context.Context, tx bun.Tx, playerID int) (*Player, error) { return GetByID[Player](tx, playerID).Relation("User").Get(ctx) } func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uint32, audit *AuditMeta) error { player, err := GetPlayer(ctx, tx, playerID) if err != nil { return errors.Wrap(err, "GetPlayer") } player.SlapID = &slapID err = UpdateByID(tx, player.ID, player).Column("slap_id"). WithAudit(audit, nil).Exec(ctx) if err != nil { return errors.Wrap(err, "UpdateByID") } return nil } // PlayerAllTimeStats holds aggregated all-time stats for a single player type PlayerAllTimeStats struct { GamesPlayed int `bun:"games_played"` PeriodsPlayed int `bun:"total_periods_played"` 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"` } // GetPlayerAllTimeStats returns aggregated all-time stats for a player // across all finalized fixture results (period 3 totals). func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } stats := new(PlayerAllTimeStats) err := tx.NewRaw(` SELECT COUNT(DISTINCT frps.fixture_result_id) AS games_played, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, 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 WHERE fr.finalized = true AND frps.player_id = ? AND frps.period_num = 3 `, playerID).Scan(ctx, stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // GetPlayerStatsBySeason returns aggregated stats for a player filtered by season. func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } if seasonID == 0 { return nil, errors.New("seasonID not provided") } stats := new(PlayerAllTimeStats) err := tx.NewRaw(` SELECT COUNT(DISTINCT frps.fixture_result_id) AS games_played, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, 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 JOIN fixtures f ON f.id = fr.fixture_id WHERE fr.finalized = true AND frps.player_id = ? AND frps.period_num = 3 AND f.season_id = ? `, playerID, seasonID).Scan(ctx, stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // GetPlayerStatsByTeam returns aggregated stats for a player filtered by team. func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } if teamID == 0 { return nil, errors.New("teamID not provided") } stats := new(PlayerAllTimeStats) err := tx.NewRaw(` SELECT COUNT(DISTINCT frps.fixture_result_id) AS games_played, COALESCE(SUM(frps.periods_played), 0) AS total_periods_played, 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 WHERE fr.finalized = true AND frps.player_id = ? AND frps.period_num = 3 AND frps.team_id = ? `, playerID, teamID).Scan(ctx, stats) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } return stats, nil } // PlayerTeamInfo holds a team the player has played on and how many seasons type PlayerTeamInfo struct { Team *Team SeasonsCount int } // GetPlayerTeams returns all teams the player has been rostered on, // with a count of distinct seasons per team. func GetPlayerTeams(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerTeamInfo, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } type teamRow struct { TeamID int `bun:"team_id"` SeasonsCount int `bun:"seasons_count"` Name string `bun:"name"` ShortName string `bun:"short_name"` AltShortName string `bun:"alt_short_name"` Color string `bun:"color"` } var rows []teamRow err := tx.NewRaw(` SELECT t.id AS team_id, t.name AS name, t.short_name AS short_name, t.alt_short_name AS alt_short_name, t.color AS color, COUNT(DISTINCT tr.season_id) AS seasons_count FROM team_rosters tr JOIN teams t ON t.id = tr.team_id WHERE tr.player_id = ? GROUP BY t.id, t.name, t.short_name, t.alt_short_name, t.color ORDER BY seasons_count DESC `, playerID).Scan(ctx, &rows) if err != nil { return nil, errors.Wrap(err, "tx.NewRaw") } var results []*PlayerTeamInfo for _, row := range rows { results = append(results, &PlayerTeamInfo{ Team: &Team{ ID: row.TeamID, Name: row.Name, ShortName: row.ShortName, AltShortName: row.AltShortName, Color: row.Color, }, SeasonsCount: row.SeasonsCount, }) } return results, nil } // PlayerSeasonInfo holds info about a player's participation in a specific season type PlayerSeasonInfo struct { Season *Season League *League Team *Team IsManager bool } // GetPlayerSeasons returns all season/league/team combos the player has been rostered in. func GetPlayerSeasons(ctx context.Context, tx bun.Tx, playerID int) ([]*PlayerSeasonInfo, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } var rosters []*TeamRoster err := tx.NewSelect(). Model(&rosters). Where("tr.player_id = ?", playerID). Relation("Season"). Relation("League"). Relation("Team"). Order("season.start_date DESC"). Scan(ctx) if err != nil { return nil, errors.Wrap(err, "tx.NewSelect") } var results []*PlayerSeasonInfo for _, r := range rosters { results = append(results, &PlayerSeasonInfo{ Season: r.Season, League: r.League, Team: r.Team, IsManager: r.IsManager, }) } return results, nil } // GetPlayerSeasonsList returns distinct seasons the player has participated in (for filter dropdowns). func GetPlayerSeasonsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Season, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } var seasons []*Season err := tx.NewSelect(). Model(&seasons). Join("JOIN team_rosters tr ON tr.season_id = s.id"). Where("tr.player_id = ?", playerID). GroupExpr("s.id"). Order("s.start_date DESC"). Scan(ctx) if err != nil { return nil, errors.Wrap(err, "tx.NewSelect") } return seasons, nil } // GetPlayerTeamsList returns distinct teams the player has played on (for filter dropdowns). func GetPlayerTeamsList(ctx context.Context, tx bun.Tx, playerID int) ([]*Team, error) { if playerID == 0 { return nil, errors.New("playerID not provided") } var teams []*Team err := tx.NewSelect(). Model(&teams). Join("JOIN team_rosters tr ON tr.team_id = t.id"). Where("tr.player_id = ?", playerID). GroupExpr("t.id"). Order("t.name ASC"). Scan(ctx) if err != nil { return nil, errors.Wrap(err, "tx.NewSelect") } return teams, nil } func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) { players, err := GetList[Player](tx).Relation("User"). Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id"). Where("NOT (tr.season_id = ? and tr.league_id = ?) OR (tr.season_id IS NULL and tr.league_id IS NULL)", seasonID, leagueID). Order("p.name ASC"). GetAll(ctx) if err != nil { return nil, errors.Wrap(err, "GetList") } return players, nil }