diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go index 4010e65..be4bbbb 100644 --- a/internal/db/fixture_result.go +++ b/internal/db/fixture_result.go @@ -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 } diff --git a/internal/db/freeagent.go b/internal/db/freeagent.go new file mode 100644 index 0000000..aca16f6 --- /dev/null +++ b/internal/db/freeagent.go @@ -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 +} diff --git a/internal/db/migrations/20260222140000_free_agents.go b/internal/db/migrations/20260222140000_free_agents.go new file mode 100644 index 0000000..a11ddc8 --- /dev/null +++ b/internal/db/migrations/20260222140000_free_agents.go @@ -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 + }, + ) +} diff --git a/internal/db/teamroster.go b/internal/db/teamroster.go index fe9fa9d..40a6203 100644 --- a/internal/db/teamroster.go +++ b/internal/db/teamroster.go @@ -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{ diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 5a70ddb..f22972c 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -52,7 +52,6 @@ --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --animate-spin: spin 1s linear infinite; - --blur-sm: 8px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -1306,6 +1305,12 @@ .text-yellow { color: var(--yellow); } + .text-yellow\/60 { + color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--yellow) 60%, transparent); + } + } .text-yellow\/70 { color: var(--yellow); @supports (color: color-mix(in lab, red, red)) { @@ -1367,11 +1372,6 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } - .backdrop-blur-sm { - --tw-backdrop-blur: blur(var(--blur-sm)); - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -1572,6 +1572,16 @@ } } } + .hover\:bg-peach\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 75%, transparent); + } + } + } + } .hover\:bg-peach\/80 { &:hover { @media (hover: hover) { @@ -1592,6 +1602,16 @@ } } } + .hover\:bg-red\/40 { + &:hover { + @media (hover: hover) { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 40%, transparent); + } + } + } + } .hover\:bg-red\/75 { &:hover { @media (hover: hover) { @@ -1872,6 +1892,14 @@ } } } + .disabled\:bg-peach\/40 { + &:disabled { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 40%, transparent); + } + } + } .disabled\:opacity-50 { &:disabled { opacity: 50%; @@ -2491,42 +2519,6 @@ syntax: "*"; inherits: false; } -@property --tw-backdrop-blur { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-brightness { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-contrast { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-grayscale { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-hue-rotate { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-invert { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-opacity { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-saturate { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-sepia { - syntax: "*"; - inherits: false; -} @property --tw-duration { syntax: "*"; inherits: false; @@ -2589,15 +2581,6 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; - --tw-backdrop-blur: initial; - --tw-backdrop-brightness: initial; - --tw-backdrop-contrast: initial; - --tw-backdrop-grayscale: initial; - --tw-backdrop-hue-rotate: initial; - --tw-backdrop-invert: initial; - --tw-backdrop-opacity: initial; - --tw-backdrop-saturate: initial; - --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; } diff --git a/internal/handlers/fixture_detail.go b/internal/handlers/fixture_detail.go index be8eafe..e662696 100644 --- a/internal/handlers/fixture_detail.go +++ b/internal/handlers/fixture_detail.go @@ -7,8 +7,10 @@ import ( "time" "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/contexts" "git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/permissions" "git.haelnorr.com/h/oslstats/internal/respond" "git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/validation" @@ -43,6 +45,8 @@ func FixtureDetailPage( var userTeamID int var result *db.FixtureResult var rosters map[string][]*db.PlayerWithPlayStatus + var nominatedFreeAgents []*db.FixtureFreeAgent + var availableFreeAgents []*db.SeasonLeagueFreeAgent if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -77,6 +81,19 @@ func FixtureDetailPage( if err != nil { return false, errors.Wrap(err, "db.GetFixtureTeamRosters") } + // Fetch free agent nominations for this fixture + nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID) + if err != nil { + return false, errors.Wrap(err, "db.GetNominatedFreeAgents") + } + // Fetch available free agents for nomination (if user can schedule or manage fixtures) + canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage) + if canSchedule || canManage { + availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID) + if err != nil { + return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague") + } + } return true, nil }); !ok { return @@ -84,7 +101,7 @@ func FixtureDetailPage( renderSafely(seasonsview.FixtureDetailPage( fixture, currentSchedule, history, canSchedule, userTeamID, - result, rosters, activeTab, + result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents, ), s, r, w) }) } @@ -108,7 +125,8 @@ func ProposeSchedule( format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash(). DayNumeric2().T().Hour24().Colon().Minute().Build() aest, _ := time.LoadLocation("Australia/Sydney") - scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value + // scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value + scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value if !getter.ValidateAndNotify(s, w, r) { return @@ -147,7 +165,7 @@ func ProposeSchedule( return } - notify.Success(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil) + notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil) respond.HXRedirect(w, "/fixtures/%d", fixtureID) }) } diff --git a/internal/handlers/fixture_result.go b/internal/handlers/fixture_result.go index e47a1b7..73cf6cf 100644 --- a/internal/handlers/fixture_result.go +++ b/internal/handlers/fixture_result.go @@ -282,6 +282,21 @@ func UploadMatchLogs( } } + // Check each player stat: if the player is a registered free agent, mark them + for _, ps := range playerStats { + if ps.PlayerID == nil { + continue + } + // Check if the player is a registered free agent + isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID) + if err != nil { + return false, errors.Wrap(err, "db.IsFreeAgentRegistered") + } + if isFA { + ps.IsFreeAgent = true + } + } + // Insert result and stats result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r)) if err != nil { @@ -294,7 +309,6 @@ func UploadMatchLogs( } _ = unmappedPlayers // stored for review page redirect - notify.Success(s, w, r, "Logs Uploaded", "Match logs have been processed. Please review the result.", nil) respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID) }) } @@ -314,6 +328,7 @@ func ReviewMatchResult( var fixture *db.Fixture var result *db.FixtureResult var unmappedPlayers []string + var faWarnings []seasonsview.FreeAgentWarning if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error @@ -335,11 +350,52 @@ func ReviewMatchResult( return false, nil } - // Build unmapped players list from stats + // Get nominated free agents for this fixture + nominatedFAs, err := db.GetNominatedFreeAgents(ctx, tx, fixtureID) + if err != nil { + return false, errors.Wrap(err, "db.GetNominatedFreeAgents") + } + // Map player ID to the side ("home"/"away") that nominated them + nominatedFASide := map[int]string{} + for _, nfa := range nominatedFAs { + if nfa.TeamID == fixture.HomeTeamID { + nominatedFASide[nfa.PlayerID] = "home" + } else { + nominatedFASide[nfa.PlayerID] = "away" + } + } + + // Helper to resolve side to team name + teamNameForSide := func(side string) string { + if side == "home" { + return fixture.HomeTeam.Name + } + return fixture.AwayTeam.Name + } + + // Build unmapped players and free agent warnings from stats + seen := map[int]bool{} for _, ps := range result.PlayerStats { - if ps.PlayerID == nil && ps.PeriodNum == 3 { + if ps.PeriodNum != 3 { + continue + } + if ps.PlayerID == nil { unmappedPlayers = append(unmappedPlayers, ps.PlayerGameUserID+" ("+ps.PlayerUsername+")") + } else if ps.IsFreeAgent && !seen[*ps.PlayerID] { + seen[*ps.PlayerID] = true + nominatedSide, wasNominated := nominatedFASide[*ps.PlayerID] + if !wasNominated { + faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{ + Name: ps.PlayerUsername, + Reason: "not nominated for this fixture", + }) + } else if nominatedSide != ps.Team { + faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{ + Name: ps.PlayerUsername, + Reason: "nominated by " + teamNameForSide(nominatedSide) + ", but played for " + teamNameForSide(ps.Team), + }) + } } } @@ -348,7 +404,7 @@ func ReviewMatchResult( return } - renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers), s, r, w) + renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), s, r, w) }) } diff --git a/internal/handlers/free_agents.go b/internal/handlers/free_agents.go new file mode 100644 index 0000000..05758af --- /dev/null +++ b/internal/handlers/free_agents.go @@ -0,0 +1,356 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/contexts" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/permissions" + "git.haelnorr.com/h/oslstats/internal/respond" + "git.haelnorr.com/h/oslstats/internal/throw" + "git.haelnorr.com/h/oslstats/internal/validation" + "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// FreeAgentsListPage renders the free agents tab of a season league page +func FreeAgentsListPage( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + leagueStr := r.PathValue("league_short_name") + + var season *db.Season + var league *db.League + var freeAgents []*db.SeasonLeagueFreeAgent + var availablePlayers []*db.Player + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetSeasonLeague") + } + season = sl.Season + league = sl.League + + freeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague") + } + + availablePlayers, err = db.GetPlayersNotOnTeam(ctx, tx, season.ID, league.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayersNotOnTeam") + } + + // Filter out players already registered as free agents + faMap := make(map[int]bool, len(freeAgents)) + for _, fa := range freeAgents { + faMap[fa.PlayerID] = true + } + filtered := make([]*db.Player, 0, len(availablePlayers)) + for _, p := range availablePlayers { + if !faMap[p.ID] { + filtered = append(filtered, p) + } + } + availablePlayers = filtered + + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(seasonsview.SeasonLeagueFreeAgentsPage(season, league, freeAgents, availablePlayers), s, r, w) + } else { + renderSafely(seasonsview.SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers), s, r, w) + } + }) +} + +// RegisterFreeAgent handles POST to register a player as a free agent +func RegisterFreeAgent( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + leagueStr := r.PathValue("league_short_name") + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + respond.BadRequest(w, errors.New("failed to parse form")) + return + } + + playerID := getter.Int("player_id").Required().Value + if !getter.ValidateAndNotify(s, w, r) { + respond.BadRequest(w, errors.New("invalid form data")) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetSeasonLeague") + } + + // Verify player is not on a team in this season_league + players, err := db.GetPlayersNotOnTeam(ctx, tx, sl.Season.ID, sl.League.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetPlayersNotOnTeam") + } + playerFound := false + for _, p := range players { + if p.ID == playerID { + playerFound = true + break + } + } + if !playerFound { + notify.Warn(s, w, r, "Cannot Register", "Player is already on a team in this league.", nil) + return false, nil + } + + // Check if already registered + isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, sl.Season.ID, sl.League.ID, playerID) + if err != nil { + return false, errors.Wrap(err, "db.IsFreeAgentRegistered") + } + if isRegistered { + notify.Warn(s, w, r, "Already Registered", "Player is already registered as a free agent.", nil) + return false, nil + } + + err = db.RegisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r)) + if err != nil { + return false, errors.Wrap(err, "db.RegisterFreeAgent") + } + + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Free Agent Registered", "Player has been registered as a free agent.", nil) + respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr) + }) +} + +// UnregisterFreeAgent handles POST to unregister a player as a free agent +func UnregisterFreeAgent( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seasonStr := r.PathValue("season_short_name") + leagueStr := r.PathValue("league_short_name") + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + respond.BadRequest(w, errors.New("failed to parse form")) + return + } + + playerID := getter.Int("player_id").Required().Value + if !getter.ValidateAndNotify(s, w, r) { + respond.BadRequest(w, errors.New("invalid form data")) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) + if err != nil { + if db.IsBadRequest(err) { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return false, errors.Wrap(err, "db.GetSeasonLeague") + } + + err = db.UnregisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r)) + if err != nil { + return false, errors.Wrap(err, "db.UnregisterFreeAgent") + } + + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Free Agent Removed", "Player has been unregistered as a free agent.", nil) + respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr) + }) +} + +// NominateFreeAgentHandler handles POST to nominate a free agent for a fixture +func NominateFreeAgentHandler( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + + playerID := getter.Int("player_id").Required().Value + teamID := getter.Int("team_id").Required().Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + // Verify fixture exists and user is a manager + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + + // Check if user can nominate: either a manager of the nominating team, + // or has fixtures.manage permission (can nominate for either team) + user := db.CurrentUser(ctx) + canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage) + if !canManage { + canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule || userTeamID != teamID { + throw.Forbidden(s, w, r, "You must be a manager of the nominating team", nil) + return false, nil + } + } + // Verify the team_id is actually one of the fixture's teams + if teamID != fixture.HomeTeamID && teamID != fixture.AwayTeamID { + throw.BadRequest(s, w, r, "Invalid team for this fixture", nil) + return false, nil + } + + // Verify player is a registered free agent in this season_league + isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, playerID) + if err != nil { + return false, errors.Wrap(err, "db.IsFreeAgentRegistered") + } + if !isRegistered { + notify.Warn(s, w, r, "Not Registered", "Player is not a registered free agent in this league.", nil) + return false, nil + } + + err = db.NominateFreeAgent(ctx, tx, fixtureID, playerID, teamID, db.NewAuditFromRequest(r)) + if err != nil { + if db.IsBadRequest(err) { + notify.Warn(s, w, r, "Cannot Nominate", err.Error(), nil) + return false, nil + } + return false, errors.Wrap(err, "db.NominateFreeAgent") + } + + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Free Agent Nominated", "Free agent has been nominated for this fixture.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} + +// RemoveFreeAgentNominationHandler handles POST to remove a free agent nomination +func RemoveFreeAgentNominationHandler( + s *hws.Server, + conn *db.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixtureID, err := strconv.Atoi(r.PathValue("fixture_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid fixture ID", err) + return + } + + playerID, err := strconv.Atoi(r.PathValue("player_id")) + if err != nil { + throw.BadRequest(s, w, r, "Invalid player ID", err) + return + } + + if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + // Check if user can remove: either has fixtures.manage permission, + // or is a manager of the team that nominated the free agent + canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage) + if !canManage { + fixture, err := db.GetFixture(ctx, tx, fixtureID) + if err != nil { + if db.IsBadRequest(err) { + respond.NotFound(w, errors.Wrap(err, "db.GetFixture")) + return false, nil + } + return false, errors.Wrap(err, "db.GetFixture") + } + user := db.CurrentUser(ctx) + canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "fixture.CanSchedule") + } + if !canSchedule { + throw.Forbidden(s, w, r, "You must be a team manager to remove nominations", nil) + return false, nil + } + // Verify the nomination belongs to the user's team + nominations, err := db.GetNominatedFreeAgentsByTeam(ctx, tx, fixtureID, userTeamID) + if err != nil { + return false, errors.Wrap(err, "db.GetNominatedFreeAgentsByTeam") + } + found := false + for _, n := range nominations { + if n.PlayerID == playerID { + found = true + break + } + } + if !found { + throw.Forbidden(s, w, r, "You can only remove nominations made by your team", nil) + return false, nil + } + } + + err := db.RemoveFreeAgentNomination(ctx, tx, fixtureID, playerID, db.NewAuditFromRequest(r)) + if err != nil { + return false, errors.Wrap(err, "db.RemoveFreeAgentNomination") + } + return true, nil + }); !ok { + return + } + + notify.Success(s, w, r, "Nomination Removed", "Free agent nomination has been removed.", nil) + respond.HXRedirect(w, "/fixtures/%d", fixtureID) + }) +} diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go index c2912fb..cbbc646 100644 --- a/internal/permissions/constants.go +++ b/internal/permissions/constants.go @@ -39,4 +39,8 @@ const ( FixturesManage Permission = "fixtures.manage" FixturesCreate Permission = "fixtures.create" FixturesDelete Permission = "fixtures.delete" + + // Free Agent permissions + FreeAgentsAdd Permission = "free_agents.add" + FreeAgentsRemove Permission = "free_agents.remove" ) diff --git a/internal/server/routes.go b/internal/server/routes.go index af54579..de3489b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -147,6 +147,22 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)), }, + // Free agent routes + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.FreeAgentsListPage(s, conn), + }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/register", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.FreeAgentsAdd)(handlers.RegisterFreeAgent(s, conn)), + }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/unregister", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.FreeAgentsRemove)(handlers.UnregisterFreeAgent(s, conn)), + }, } leagueRoutes := []hws.Route{ @@ -234,6 +250,17 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)), }, + // Fixture free agent nomination routes + { + Path: "/fixtures/{fixture_id}/free-agents/nominate", + Method: hws.MethodPOST, + Handler: handlers.NominateFreeAgentHandler(s, conn), + }, + { + Path: "/fixtures/{fixture_id}/free-agents/{player_id}/remove", + Method: hws.MethodPOST, + Handler: handlers.RemoveFreeAgentNominationHandler(s, conn), + }, // Match result management routes (all require fixtures.manage permission) { Path: "/fixtures/{fixture_id}/results/upload", diff --git a/internal/view/seasonsview/fixture_detail.templ b/internal/view/seasonsview/fixture_detail.templ index 50d1d9c..a9147f8 100644 --- a/internal/view/seasonsview/fixture_detail.templ +++ b/internal/view/seasonsview/fixture_detail.templ @@ -17,6 +17,8 @@ templ FixtureDetailPage( result *db.FixtureResult, rosters map[string][]*db.PlayerWithPlayStatus, activeTab string, + nominatedFreeAgents []*db.FixtureFreeAgent, + availableFreeAgents []*db.SeasonLeagueFreeAgent, ) { {{ permCache := contexts.Permissions(ctx) @@ -78,10 +80,10 @@ templ FixtureDetailPage( } - - if activeTab == "overview" { - @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage) - } else if activeTab == "schedule" { + + if activeTab == "overview" { + @fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents) + } else if activeTab == "schedule" { @fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID) } @@ -116,6 +118,10 @@ templ fixtureOverviewTab( result *db.FixtureResult, rosters map[string][]*db.PlayerWithPlayStatus, canManage bool, + canSchedule bool, + userTeamID int, + nominatedFreeAgents []*db.FixtureFreeAgent, + availableFreeAgents []*db.SeasonLeagueFreeAgent, ) {
No free agents nominated for this fixture.
+ } else { +{ fixture.HomeTeam.Name }
+ if len(homeNominated) == 0 { +None
+ } else { +{ fixture.AwayTeam.Name }
+ if len(awayNominated) == 0 { +None
+ } else { +{ *result.TamperingReason }
-- This does not block finalization but should be reviewed carefully. -
+{ *result.TamperingReason }
++ This does not block finalization but should be reviewed carefully. +
+- The following players could not be matched to registered players. - They may be free agents or have unregistered Slapshot IDs. -
-+ The following free agents have nomination issues that should be reviewed before finalizing. +
++ The following players could not be matched to registered players. + They may be free agents or have unregistered Slapshot IDs. +
+No free agents registered in this league yet.
+ if canAdd { +Click "Add Free Agent" to register a player.
+ } +| Player | +Registered By | + if canRemove { +Actions | + } +
|---|---|---|
| + + { fa.Player.DisplayName() } + + FREE AGENT + + + | ++ if fa.RegisteredBy != nil { + { fa.RegisteredBy.Username } + } + | + if canRemove { ++ + | + } +