added free agents

This commit is contained in:
2026-02-22 22:44:17 +11:00
parent d011c7acb8
commit f3d1395076
15 changed files with 1549 additions and 98 deletions

View File

@@ -79,6 +79,7 @@ type FixtureResultPlayerStats struct {
HasMercyRuled *int
WasMercyRuled *int
PeriodsPlayed *int
IsFreeAgent bool `bun:"is_free_agent,default:false"`
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
@@ -87,10 +88,11 @@ type FixtureResultPlayerStats struct {
// 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
Player *Player
Played bool
IsManager bool
IsFreeAgent 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.
@@ -489,6 +491,12 @@ func GetFixtureTeamRosters(
// Build maps of player IDs that played and their period 3 stats
playedPlayerIDs := map[int]bool{}
playerStatsByID := map[int]*FixtureResultPlayerStats{}
freeAgentPlayerIDs := map[int]bool{}
// Track free agents by team side for roster inclusion
freeAgentsByTeam := map[string]map[int]*FixtureResultPlayerStats{} // "home"/"away" -> playerID -> stats
freeAgentsByTeam["home"] = map[int]*FixtureResultPlayerStats{}
freeAgentsByTeam["away"] = map[int]*FixtureResultPlayerStats{}
if result != nil {
for _, ps := range result.PlayerStats {
if ps.PlayerID != nil {
@@ -496,10 +504,29 @@ func GetFixtureTeamRosters(
if ps.PeriodNum == 3 {
playerStatsByID[*ps.PlayerID] = ps
}
if ps.IsFreeAgent {
freeAgentPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
freeAgentsByTeam[ps.Team][*ps.PlayerID] = ps
}
}
}
}
}
// Build a set of roster player IDs so we can skip them when adding free agents
rosterPlayerIDs := map[int]bool{}
for _, r := range homeRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
for _, r := range awayRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
// Build home roster with play status and stats
for _, r := range homeRosters {
played := false
@@ -532,5 +559,29 @@ func GetFixtureTeamRosters(
})
}
// Add free agents who played but are not on the team roster
for team, faStats := range freeAgentsByTeam {
for playerID, stats := range faStats {
if rosterPlayerIDs[playerID] {
continue // Already on the roster, skip
}
if stats.Player == nil {
// Try to load the player
player, err := GetPlayer(ctx, tx, playerID)
if err != nil {
continue // Skip if we can't load
}
stats.Player = player
}
rosters[team] = append(rosters[team], &PlayerWithPlayStatus{
Player: stats.Player,
Played: true,
IsManager: false,
IsFreeAgent: true,
Stats: stats,
})
}
}
return rosters, nil
}

342
internal/db/freeagent.go Normal file
View File

@@ -0,0 +1,342 @@
package db
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// SeasonLeagueFreeAgent tracks players registered as free agents in a season_league.
type SeasonLeagueFreeAgent struct {
bun.BaseModel `bun:"table:season_league_free_agents,alias:slfa"`
SeasonID int `bun:",pk,notnull"`
LeagueID int `bun:",pk,notnull"`
PlayerID int `bun:",pk,notnull"`
RegisteredAt int64 `bun:",notnull"`
RegisteredByUserID int `bun:",notnull"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
RegisteredBy *User `bun:"rel:belongs-to,join:registered_by_user_id=id"`
}
// FixtureFreeAgent tracks which free agents are nominated for specific fixtures.
type FixtureFreeAgent struct {
bun.BaseModel `bun:"table:fixture_free_agents,alias:ffa"`
FixtureID int `bun:",pk,notnull"`
PlayerID int `bun:",pk,notnull"`
TeamID int `bun:",notnull"`
NominatedByUserID int `bun:",notnull"`
NominatedAt int64 `bun:",notnull"`
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
NominatedBy *User `bun:"rel:belongs-to,join:nominated_by_user_id=id"`
}
// RegisterFreeAgent registers a player as a free agent in a season_league.
func RegisterFreeAgent(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
audit *AuditMeta,
) error {
user := CurrentUser(ctx)
if user == nil {
return errors.New("user cannot be nil")
}
entry := &SeasonLeagueFreeAgent{
SeasonID: seasonID,
LeagueID: leagueID,
PlayerID: playerID,
RegisteredAt: time.Now().Unix(),
RegisteredByUserID: user.ID,
}
info := &AuditInfo{
Action: "free_agents.add",
ResourceType: "season_league_free_agent",
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
Details: map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
},
}
err := Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// UnregisterFreeAgent removes a player's free agent registration and all their nominations.
func UnregisterFreeAgent(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
audit *AuditMeta,
) error {
// First remove all nominations for this player
err := RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
}
// Then remove the registration
_, err = tx.NewDelete().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
info := &AuditInfo{
Action: "free_agents.remove",
ResourceType: "season_league_free_agent",
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
Details: map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
},
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
// GetFreeAgentsForSeasonLeague returns all players registered as free agents in a season_league.
func GetFreeAgentsForSeasonLeague(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*SeasonLeagueFreeAgent, error) {
entries := []*SeasonLeagueFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("slfa.season_id = ?", seasonID).
Where("slfa.league_id = ?", leagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Relation("RegisteredBy").
Order("slfa.registered_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// IsFreeAgentRegistered checks if a player is registered as a free agent in a season_league.
func IsFreeAgentRegistered(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
) (bool, error) {
count, err := tx.NewSelect().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("player_id = ?", playerID).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count > 0, nil
}
// NominateFreeAgent nominates a free agent for a specific fixture on behalf of a team.
func NominateFreeAgent(
ctx context.Context,
tx bun.Tx,
fixtureID, playerID, teamID int,
audit *AuditMeta,
) error {
user := CurrentUser(ctx)
if user == nil {
return errors.New("user cannot be nil")
}
// Check if already nominated by another team
existing := new(FixtureFreeAgent)
err := tx.NewSelect().
Model(existing).
Where("ffa.fixture_id = ?", fixtureID).
Where("ffa.player_id = ?", playerID).
Scan(ctx)
if err == nil {
// Found existing nomination
if existing.TeamID != teamID {
return BadRequest("Player already nominated for this fixture by another team")
}
return BadRequest("Player already nominated for this fixture")
}
if err.Error() != "sql: no rows in result set" {
return errors.Wrap(err, "tx.NewSelect")
}
// Check max 2 free agents per team per fixture
count, err := tx.NewSelect().
Model((*FixtureFreeAgent)(nil)).
Where("fixture_id = ?", fixtureID).
Where("team_id = ?", teamID).
Count(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewSelect count")
}
if count >= 2 {
return BadRequest("Maximum of 2 free agents per team per fixture")
}
entry := &FixtureFreeAgent{
FixtureID: fixtureID,
PlayerID: playerID,
TeamID: teamID,
NominatedByUserID: user.ID,
NominatedAt: time.Now().Unix(),
}
info := &AuditInfo{
Action: "free_agents.nominate",
ResourceType: "fixture_free_agent",
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
Details: map[string]any{
"fixture_id": fixtureID,
"player_id": playerID,
"team_id": teamID,
},
}
err = Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// GetNominatedFreeAgents returns all free agents nominated for a fixture.
func GetNominatedFreeAgents(
ctx context.Context,
tx bun.Tx,
fixtureID int,
) ([]*FixtureFreeAgent, error) {
entries := []*FixtureFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("ffa.fixture_id = ?", fixtureID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Relation("Team").
Relation("NominatedBy").
Order("ffa.nominated_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// GetNominatedFreeAgentsByTeam returns free agents nominated by a specific team for a fixture.
func GetNominatedFreeAgentsByTeam(
ctx context.Context,
tx bun.Tx,
fixtureID, teamID int,
) ([]*FixtureFreeAgent, error) {
entries := []*FixtureFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("ffa.fixture_id = ?", fixtureID).
Where("ffa.team_id = ?", teamID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Order("ffa.nominated_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// RemoveAllFreeAgentNominationsForPlayer deletes all nominations for a player.
// Used for cascade deletion on team join and unregister.
func RemoveAllFreeAgentNominationsForPlayer(
ctx context.Context,
tx bun.Tx,
playerID int,
) error {
_, err := tx.NewDelete().
Model((*FixtureFreeAgent)(nil)).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}
// RemoveFreeAgentNomination removes a specific nomination.
func RemoveFreeAgentNomination(
ctx context.Context,
tx bun.Tx,
fixtureID, playerID int,
audit *AuditMeta,
) error {
_, err := tx.NewDelete().
Model((*FixtureFreeAgent)(nil)).
Where("fixture_id = ?", fixtureID).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
info := &AuditInfo{
Action: "free_agents.remove_nomination",
ResourceType: "fixture_free_agent",
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
Details: map[string]any{
"fixture_id": fixtureID,
"player_id": playerID,
},
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
// RemoveFreeAgentRegistrationForPlayer removes all free agent registrations for a player.
// Used on team join.
func RemoveFreeAgentRegistrationForPlayer(
ctx context.Context,
tx bun.Tx,
playerID int,
) error {
_, err := tx.NewDelete().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}

View File

@@ -0,0 +1,91 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Create season_league_free_agents table
_, err := conn.NewCreateTable().
Model((*db.SeasonLeagueFreeAgent)(nil)).
IfNotExists().
ForeignKey(`("season_id") REFERENCES "seasons" ("id") ON DELETE CASCADE`).
ForeignKey(`("league_id") REFERENCES "leagues" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
ForeignKey(`("registered_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create fixture_free_agents table
_, err = conn.NewCreateTable().
Model((*db.FixtureFreeAgent)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE CASCADE`).
ForeignKey(`("nominated_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_free_agents for team lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureFreeAgent)(nil)).
Index("idx_ffa_fixture_team").
Column("fixture_id", "team_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add is_free_agent column to fixture_result_player_stats
_, err = conn.NewAddColumn().
Model((*db.FixtureResultPlayerStats)(nil)).
ColumnExpr("is_free_agent BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Drop is_free_agent column from fixture_result_player_stats
_, err := conn.NewDropColumn().
Model((*db.FixtureResultPlayerStats)(nil)).
ColumnExpr("is_free_agent").
Exec(ctx)
if err != nil {
return err
}
// Drop fixture_free_agents table
_, err = conn.NewDropTable().
Model((*db.FixtureFreeAgent)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
// Drop season_league_free_agents table
_, err = conn.NewDropTable().
Model((*db.SeasonLeagueFreeAgent)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

View File

@@ -128,6 +128,7 @@ func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID,
// ManageTeamRoster replaces the entire roster for a team in a season/league.
// It deletes all existing roster entries and inserts the new ones.
// Also auto-removes free agent registrations and nominations for players joining a team.
func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, managerID int, playerIDs []int, audit *AuditMeta) error {
// Delete all existing roster entries for this team/season/league
_, err := tx.NewDelete().
@@ -140,6 +141,56 @@ func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID
return errors.Wrap(err, "delete existing roster")
}
// Collect all player IDs being added (including manager)
allPlayerIDs := make([]int, 0, len(playerIDs)+1)
if managerID > 0 {
allPlayerIDs = append(allPlayerIDs, managerID)
}
for _, pid := range playerIDs {
if pid != managerID {
allPlayerIDs = append(allPlayerIDs, pid)
}
}
// Auto-remove free agent registrations and nominations for players joining a team
for _, playerID := range allPlayerIDs {
// Check if the player is a registered free agent
isFA, err := IsFreeAgentRegistered(ctx, tx, seasonID, leagueID, playerID)
if err != nil {
return errors.Wrap(err, "IsFreeAgentRegistered")
}
if isFA {
// Remove all nominations for this player
err = RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
}
// Remove free agent registration
err = RemoveFreeAgentRegistrationForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveFreeAgentRegistrationForPlayer")
}
// Log the cascade action
if audit != nil {
cascadeInfo := &AuditInfo{
"free_agents.auto_removed_on_team_join",
"season_league_free_agent",
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
"team_id": teamID,
},
}
err = LogSuccess(ctx, tx, audit, cascadeInfo)
if err != nil {
return errors.Wrap(err, "LogSuccess cascade")
}
}
}
}
// Insert manager if provided
if managerID > 0 {
tr := &TeamRoster{