added free agents

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

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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)
})
}