diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go index a400a80..f98ca88 100644 --- a/cmd/oslstats/run.go +++ b/cmd/oslstats/run.go @@ -17,6 +17,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/embedfs" "git.haelnorr.com/h/oslstats/internal/server" "git.haelnorr.com/h/oslstats/internal/store" + "git.haelnorr.com/h/oslstats/pkg/slapshotapi" ) // Initializes and runs the server @@ -47,8 +48,15 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { return errors.Wrap(err, "discord.NewAPIClient") } + // Setup Slapshot API + logger.Debug().Msg("Setting up Slapshot API client") + slapAPI, err := slapshotapi.NewSlapAPIClient(cfg.Slapshot) + if err != nil { + return errors.Wrap(err, "slapshotapi.NewSlapAPIClient") + } + logger.Debug().Msg("Setting up HTTP server") - httpServer, err := server.Setup(staticFS, cfg, logger, conn, store, discordAPI) + httpServer, err := server.Setup(staticFS, cfg, logger, conn, store, discordAPI, slapAPI) if err != nil { return errors.Wrap(err, "setupHttpServer") } diff --git a/go.mod b/go.mod index 7db4d2a..b4d5e0a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/uptrace/bun v1.2.16 github.com/uptrace/bun/dialect/pgdialect v1.2.16 github.com/uptrace/bun/driver/pgdriver v1.2.16 + golang.org/x/time v0.14.0 ) require ( diff --git a/go.sum b/go.sum index 16f49fb..dcf3129 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 3cd6094..9643062 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,19 +10,21 @@ import ( "git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/rbac" "git.haelnorr.com/h/oslstats/pkg/oauth" + "git.haelnorr.com/h/oslstats/pkg/slapshotapi" "github.com/joho/godotenv" "github.com/pkg/errors" ) type Config struct { - DB *db.Config - HWS *hws.Config - HWSAuth *hwsauth.Config - HLOG *hlog.Config - Discord *discord.Config - OAuth *oauth.Config - RBAC *rbac.Config - Flags *Flags + DB *db.Config + HWS *hws.Config + HWSAuth *hwsauth.Config + HLOG *hlog.Config + Discord *discord.Config + OAuth *oauth.Config + RBAC *rbac.Config + Slapshot *slapshotapi.Config + Flags *Flags } // GetConfig loads the application configuration and returns a pointer to the Config object @@ -42,6 +44,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) { discord.NewEZConfIntegration(), oauth.NewEZConfIntegration(), rbac.NewEZConfIntegration(), + slapshotapi.NewEZConfIntegration(), ) if err != nil { return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations") @@ -93,15 +96,21 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) { return nil, nil, errors.New("RBAC Config not loaded") } + slapcfg, ok := loader.GetConfig("slapshotapi") + if !ok { + return nil, nil, errors.New("SlapshotAPI Config not loaded") + } + config := &Config{ - DB: dbcfg.(*db.Config), - HWS: hwscfg.(*hws.Config), - HWSAuth: hwsauthcfg.(*hwsauth.Config), - HLOG: hlogcfg.(*hlog.Config), - Discord: discordcfg.(*discord.Config), - OAuth: oauthcfg.(*oauth.Config), - RBAC: rbaccfg.(*rbac.Config), - Flags: flags, + DB: dbcfg.(*db.Config), + HWS: hwscfg.(*hws.Config), + HWSAuth: hwsauthcfg.(*hwsauth.Config), + HLOG: hlogcfg.(*hlog.Config), + Discord: discordcfg.(*discord.Config), + OAuth: oauthcfg.(*oauth.Config), + RBAC: rbaccfg.(*rbac.Config), + Slapshot: slapcfg.(*slapshotapi.Config), + Flags: flags, } return config, loader, nil diff --git a/internal/db/audit.go b/internal/db/audit.go index 98b6817..e6fff1f 100644 --- a/internal/db/audit.go +++ b/internal/db/audit.go @@ -7,15 +7,18 @@ import ( ) type AuditMeta struct { - r *http.Request - u *User + ipAddress string + userAgent string + u *User } -func NewAudit(r *http.Request, u *User) *AuditMeta { - if u == nil { - u = CurrentUser(r.Context()) - } - return &AuditMeta{r, u} +func NewAudit(ipAdd, agent string, user *User) *AuditMeta { + return &AuditMeta{ipAdd, agent, user} +} + +func NewAuditFromRequest(r *http.Request) *AuditMeta { + u := CurrentUser(r.Context()) + return &AuditMeta{r.RemoteAddr, r.UserAgent(), u} } // AuditInfo contains metadata for audit logging @@ -45,7 +48,10 @@ func extractTableName[T any]() string { if bunTag != "" { // Parse tag: "table:seasons,alias:s" -> "seasons" for part := range strings.SplitSeq(bunTag, ",") { - part, _ := strings.CutPrefix(part, "table:") + part, match := strings.CutPrefix(part, "table:") + if match { + return part + } return part } } @@ -56,6 +62,38 @@ func extractTableName[T any]() string { return strings.ToLower(t.Name()) + "s" } +// extractTableName gets the bun table alias from a model type using reflection +// Example: Season with `bun:"table:seasons,alias:s"` returns "s" +func extractTableAlias[T any]() string { + var model T + t := reflect.TypeOf(model) + + // Handle pointer types + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // Look for bun.BaseModel field with table tag + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Type.Name() == "BaseModel" { + bunTag := field.Tag.Get("bun") + if bunTag != "" { + // Parse tag: "table:seasons,alias:s" -> "seasons" + for part := range strings.SplitSeq(bunTag, ",") { + part, match := strings.CutPrefix(part, "alias:") + if match { + return part + } + } + } + } + } + + // Fallback: use struct name in lowercase + "s" + return strings.ToLower(t.Name()) + "s" +} + // extractResourceType converts a table name to singular resource type // Example: "seasons" -> "season", "users" -> "user" func extractResourceType(tableName string) string { diff --git a/internal/db/auditlog.go b/internal/db/auditlog.go index 8727c1c..f615e93 100644 --- a/internal/db/auditlog.go +++ b/internal/db/auditlog.go @@ -3,7 +3,6 @@ package db import ( "context" "encoding/json" - "fmt" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -78,7 +77,6 @@ func (a *AuditLogFilter) UserIDs(ids []int) *AuditLogFilter { } func (a *AuditLogFilter) Actions(actions []string) *AuditLogFilter { - fmt.Println(actions) if len(actions) > 0 { a.In("al.action", actions) } diff --git a/internal/db/auditlogger.go b/internal/db/auditlogger.go index 988d4d6..02a2831 100644 --- a/internal/db/auditlogger.go +++ b/internal/db/auditlogger.go @@ -49,9 +49,6 @@ func log( if meta.u == nil { return errors.New("user cannot be nil for audit logging") } - if meta.r == nil { - return errors.New("request cannot be nil for audit logging") - } // Convert resourceID to string var resourceIDStr *string @@ -70,18 +67,14 @@ func log( detailsJSON = jsonBytes } - // Extract IP and User-Agent from request - ipAddress := meta.r.RemoteAddr - userAgent := meta.r.UserAgent() - log := &AuditLog{ UserID: meta.u.ID, Action: info.Action, ResourceType: info.ResourceType, ResourceID: resourceIDStr, Details: detailsJSON, - IPAddress: ipAddress, - UserAgent: userAgent, + IPAddress: meta.ipAddress, + UserAgent: meta.userAgent, Result: result, ErrorMessage: errorMessage, CreatedAt: time.Now().Unix(), diff --git a/internal/db/fixture.go b/internal/db/fixture.go index 15b0f71..dcfec95 100644 --- a/internal/db/fixture.go +++ b/internal/db/fixture.go @@ -28,12 +28,41 @@ type Fixture struct { League *League `bun:"rel:belongs-to,join:league_id=id"` HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"` AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"` + + Schedules []*FixtureSchedule `bun:"rel:has-many,join:id=fixture_id"` +} + +// CanSchedule checks if the user is a manager of one of the teams in the fixture. +// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager). +func (f *Fixture) CanSchedule(ctx context.Context, tx bun.Tx, user *User) (bool, int, error) { + if user == nil || user.Player == nil { + return false, 0, nil + } + roster := new(TeamRoster) + err := tx.NewSelect(). + Model(roster). + Column("team_id", "is_manager"). + Where("team_id IN (?)", bun.In([]int{f.HomeTeamID, f.AwayTeamID})). + Where("season_id = ?", f.SeasonID). + Where("league_id = ?", f.LeagueID). + Where("player_id = ?", user.Player.ID). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return false, 0, nil + } + return false, 0, errors.Wrap(err, "tx.NewSelect") + } + if !roster.IsManager { + return false, 0, nil + } + return true, roster.TeamID, nil } func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, homeTeamID, awayTeamID, round int, audit *AuditMeta, ) (*Fixture, error) { - season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName) if err != nil { return nil, errors.Wrap(err, "GetSeasonLeague") } @@ -59,7 +88,7 @@ func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, round int, audit *AuditMeta, ) ([]*Fixture, error) { - season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName) if err != nil { return nil, errors.Wrap(err, "GetSeasonLeague") } @@ -71,22 +100,22 @@ func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName s return fixtures, nil } -func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) { - season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) +func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) { + sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) if err != nil { - return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague") + return nil, nil, errors.Wrap(err, "GetSeasonLeague") } fixtures, err := GetList[Fixture](tx). - Where("season_id = ?", season.ID). - Where("league_id = ?", league.ID). + Where("season_id = ?", sl.SeasonID). + Where("league_id = ?", sl.LeagueID). Order("game_week ASC NULLS FIRST", "round ASC", "id ASC"). Relation("HomeTeam"). Relation("AwayTeam"). GetAll(ctx) if err != nil { - return nil, nil, nil, errors.Wrap(err, "GetList") + return nil, nil, errors.Wrap(err, "GetList") } - return season, league, fixtures, nil + return sl, fixtures, nil } func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) { @@ -98,6 +127,38 @@ func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) { Get(ctx) } +// GetAllocatedFixtures returns all fixtures with a game_week assigned for a season+league. +func GetAllocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) { + fixtures, err := GetList[Fixture](tx). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("game_week IS NOT NULL"). + Order("game_week ASC", "round ASC", "id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return fixtures, nil +} + +func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) { + fixtures, err := GetList[Fixture](tx). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("game_week IS NOT NULL"). + Where("(home_team_id = ? OR away_team_id = ?)", teamID, teamID). + Order("game_week ASC", "round ASC", "id ASC"). + Relation("HomeTeam"). + Relation("AwayTeam"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return fixtures, nil +} + func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) { fixtures, err := GetList[Fixture](tx). Where("season_id = ?", seasonID). @@ -180,13 +241,13 @@ func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture, } func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error { - season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) if err != nil { return errors.Wrap(err, "GetSeasonLeague") } err = DeleteItem[Fixture](tx). - Where("season_id = ?", season.ID). - Where("league_id = ?", league.ID). + Where("season_id = ?", sl.SeasonID). + Where("league_id = ?", sl.LeagueID). WithAudit(audit, nil). Delete(ctx) if err != nil { @@ -269,7 +330,7 @@ func playOtherTeams(team *Team, teams []*Team, round int) []*versus { matchups := make([]*versus, len(teams)) for i, opponent := range teams { versus := &versus{} - if i%2+round%2 == 0 { + if (i+round)%2 == 0 { versus.homeTeam = team versus.awayTeam = opponent } else { @@ -280,3 +341,40 @@ func playOtherTeams(team *Team, teams []*Team, round int) []*versus { } return matchups } + +func AutoAllocateFixtures(fixtures []*Fixture, gamesPerWeek, startingWeek int) []*Fixture { + gameWeek := startingWeek + teamPlays := map[int]int{} + // Work on a copy so we can track what's remaining + remaining := make([]*Fixture, len(fixtures)) + copy(remaining, fixtures) + for len(remaining) > 0 { + madeProgress := false + nextRemaining := make([]*Fixture, 0, len(remaining)) + for _, fixture := range remaining { + if teamPlays[fixture.HomeTeamID] < gamesPerWeek && + teamPlays[fixture.AwayTeamID] < gamesPerWeek { + gw := gameWeek + fixture.GameWeek = &gw + teamPlays[fixture.HomeTeamID]++ + teamPlays[fixture.AwayTeamID]++ + madeProgress = true + } else { + nextRemaining = append(nextRemaining, fixture) + } + } + if !madeProgress { + // No fixture could be placed this week — advance to avoid infinite loop + // (shouldn't happen with valid fixture data, but guards against edge cases) + gameWeek++ + teamPlays = map[int]int{} + continue + } + remaining = nextRemaining + if len(remaining) > 0 { + gameWeek++ + teamPlays = map[int]int{} + } + } + return fixtures +} diff --git a/internal/db/fixture_result.go b/internal/db/fixture_result.go new file mode 100644 index 0000000..be4bbbb --- /dev/null +++ b/internal/db/fixture_result.go @@ -0,0 +1,587 @@ +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 + 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"` + 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 + 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. +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{} + 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 { + playedPlayerIDs[*ps.PlayerID] = true + 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 + 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, + }) + } + + // 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/fixture_schedule.go b/internal/db/fixture_schedule.go new file mode 100644 index 0000000..6d4ec13 --- /dev/null +++ b/internal/db/fixture_schedule.go @@ -0,0 +1,426 @@ +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// ScheduleStatus represents the current status of a fixture schedule proposal +type ScheduleStatus string + +const ( + ScheduleStatusPending ScheduleStatus = "pending" + ScheduleStatusAccepted ScheduleStatus = "accepted" + ScheduleStatusRejected ScheduleStatus = "rejected" + ScheduleStatusRescheduled ScheduleStatus = "rescheduled" + ScheduleStatusPostponed ScheduleStatus = "postponed" + ScheduleStatusCancelled ScheduleStatus = "cancelled" + ScheduleStatusWithdrawn ScheduleStatus = "withdrawn" +) + +// IsTerminal returns true if the status is a terminal (immutable) state +func (s ScheduleStatus) IsTerminal() bool { + switch s { + case ScheduleStatusRejected, ScheduleStatusRescheduled, + ScheduleStatusPostponed, ScheduleStatusCancelled, + ScheduleStatusWithdrawn: + return true + } + return false +} + +// RescheduleReason represents the predefined reasons for rescheduling or postponing +type RescheduleReason string + +const ( + ReasonMutuallyAgreed RescheduleReason = "Mutually Agreed" + ReasonTeamUnavailable RescheduleReason = "Team Unavailable" + ReasonTeamNoShow RescheduleReason = "Team No-show" +) + +type FixtureSchedule struct { + bun.BaseModel `bun:"table:fixture_schedules,alias:fs"` + + ID int `bun:"id,pk,autoincrement"` + FixtureID int `bun:",notnull"` + ScheduledTime *time.Time `bun:"scheduled_time"` + ProposedByTeamID int `bun:",notnull"` + AcceptedByTeamID *int `bun:"accepted_by_team_id"` + Status ScheduleStatus `bun:",notnull,default:'pending'"` + RescheduleReason *string `bun:"reschedule_reason"` + CreatedAt int64 `bun:",notnull"` + UpdatedAt *int64 `bun:"updated_at"` + + Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"` + ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"` + AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"` +} + +// GetAcceptedSchedulesForFixtures returns the accepted schedule for each fixture in the given list. +// Returns a map of fixtureID -> *FixtureSchedule (only accepted schedules are included). +func GetAcceptedSchedulesForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureSchedule, error) { + if len(fixtureIDs) == 0 { + return map[int]*FixtureSchedule{}, nil + } + schedules, err := GetList[FixtureSchedule](tx). + Where("fixture_id IN (?)", bun.In(fixtureIDs)). + Where("status = ?", ScheduleStatusAccepted). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + result := make(map[int]*FixtureSchedule, len(schedules)) + for _, s := range schedules { + // If multiple accepted exist (shouldn't happen), keep the most recent + existing, ok := result[s.FixtureID] + if !ok || s.CreatedAt > existing.CreatedAt { + result[s.FixtureID] = s + } + } + return result, nil +} + +// GetFixtureScheduleHistory returns all schedule records for a fixture in chronological order +func GetFixtureScheduleHistory(ctx context.Context, tx bun.Tx, fixtureID int) ([]*FixtureSchedule, error) { + schedules, err := GetList[FixtureSchedule](tx). + Where("fixture_id = ?", fixtureID). + Order("created_at ASC", "id ASC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + GetAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "GetList") + } + return schedules, nil +} + +// GetCurrentFixtureSchedule returns the most recent schedule record for a fixture. +// Returns nil, nil if no schedule exists. +func GetCurrentFixtureSchedule(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureSchedule, error) { + schedule := new(FixtureSchedule) + err := tx.NewSelect(). + Model(schedule). + Where("fixture_id = ?", fixtureID). + Order("created_at DESC", "id DESC"). + Relation("ProposedBy"). + Relation("AcceptedBy"). + Limit(1). + 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 schedule, nil +} + +// ProposeFixtureSchedule creates a new pending schedule proposal for a fixture. +// If there is an existing pending record with no time (postponed placeholder), it will be +// superseded. Cannot propose on cancelled or accepted schedules. +func ProposeFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID, proposedByTeamID int, + scheduledTime time.Time, + audit *AuditMeta, +) (*FixtureSchedule, error) { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current != nil { + switch current.Status { + case ScheduleStatusCancelled: + return nil, BadRequest("cannot propose a new time for a cancelled fixture") + case ScheduleStatusAccepted: + return nil, BadRequest("fixture already has an accepted schedule; use reschedule instead") + case ScheduleStatusPending: + // Supersede existing pending record (e.g., postponed placeholder or old proposal) + now := time.Now().Unix() + current.Status = ScheduleStatusRescheduled + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "updated_at"). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "UpdateByID") + } + // rejected, rescheduled, postponed are terminal — safe to create a new proposal + } + } + + schedule := &FixtureSchedule{ + FixtureID: fixtureID, + ScheduledTime: &scheduledTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.propose", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "proposed_by": proposedByTeamID, + "scheduled_time": scheduledTime, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// AcceptFixtureSchedule accepts a pending schedule proposal. +// The acceptedByTeamID must be the other team (not the proposer). +func AcceptFixtureSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, acceptedByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + if schedule.ProposedByTeamID == acceptedByTeamID { + return BadRequest("cannot accept your own proposal") + } + + now := time.Now().Unix() + schedule.AcceptedByTeamID = &acceptedByTeamID + schedule.Status = ScheduleStatusAccepted + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("accepted_by_team_id", "status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.accept", + ResourceType: "fixture_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "fixture_id": schedule.FixtureID, + "accepted_by": acceptedByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RejectFixtureSchedule rejects a pending schedule proposal. +func RejectFixtureSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + + now := time.Now().Unix() + schedule.Status = ScheduleStatusRejected + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.reject", + ResourceType: "fixture_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "fixture_id": schedule.FixtureID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// RescheduleFixtureSchedule marks the current accepted schedule as rescheduled and creates +// a new pending proposal with the new time. +func RescheduleFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID, proposedByTeamID int, + newTime time.Time, + reason string, + audit *AuditMeta, +) (*FixtureSchedule, error) { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return nil, errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current == nil || current.Status != ScheduleStatusAccepted { + return nil, BadRequest("no accepted schedule to reschedule") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusRescheduled + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "UpdateByID") + } + + // Create new pending proposal + schedule := &FixtureSchedule{ + FixtureID: fixtureID, + ScheduledTime: &newTime, + ProposedByTeamID: proposedByTeamID, + Status: ScheduleStatusPending, + CreatedAt: time.Now().Unix(), + } + err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.reschedule", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "proposed_by": proposedByTeamID, + "new_time": newTime, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return schedule, nil +} + +// PostponeFixtureSchedule marks the current accepted schedule as postponed. +// This is a terminal state — a new proposal can be created afterwards. +func PostponeFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current == nil || current.Status != ScheduleStatusAccepted { + return BadRequest("no accepted schedule to postpone") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusPostponed + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.postpone", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "reason": reason, + "old_schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// WithdrawFixtureSchedule allows the proposer to withdraw their pending proposal. +// Only the team that proposed can withdraw it. +func WithdrawFixtureSchedule( + ctx context.Context, + tx bun.Tx, + scheduleID, withdrawByTeamID int, + audit *AuditMeta, +) error { + schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetByID") + } + if schedule.Status != ScheduleStatusPending { + return BadRequest("schedule is not in pending status") + } + if schedule.ProposedByTeamID != withdrawByTeamID { + return BadRequest("only the proposing team can withdraw their proposal") + } + + now := time.Now().Unix() + schedule.Status = ScheduleStatusWithdrawn + schedule.UpdatedAt = &now + err = UpdateByID(tx, schedule.ID, schedule). + Column("status", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.withdraw", + ResourceType: "fixture_schedule", + ResourceID: scheduleID, + Details: map[string]any{ + "fixture_id": schedule.FixtureID, + "withdrawn_by": withdrawByTeamID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} + +// CancelFixtureSchedule marks the current schedule as cancelled. This is a terminal state. +// Requires fixtures.manage permission (moderator-level). +func CancelFixtureSchedule( + ctx context.Context, + tx bun.Tx, + fixtureID int, + reason string, + audit *AuditMeta, +) error { + current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID) + if err != nil { + return errors.Wrap(err, "GetCurrentFixtureSchedule") + } + if current == nil { + return BadRequest("no schedule to cancel") + } + if current.Status.IsTerminal() { + return BadRequest("schedule is already in a terminal state") + } + + now := time.Now().Unix() + current.Status = ScheduleStatusCancelled + current.RescheduleReason = &reason + current.UpdatedAt = &now + err = UpdateByID(tx, current.ID, current). + Column("status", "reschedule_reason", "updated_at"). + WithAudit(audit, &AuditInfo{ + Action: "fixture_schedule.cancel", + ResourceType: "fixture_schedule", + ResourceID: fixtureID, + Details: map[string]any{ + "fixture_id": fixtureID, + "reason": reason, + "schedule_id": current.ID, + }, + }).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return 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/getbyfield.go b/internal/db/getbyfield.go index 7a86944..61cdfe8 100644 --- a/internal/db/getbyfield.go +++ b/internal/db/getbyfield.go @@ -37,6 +37,10 @@ func (g *fieldgetter[T]) Get(ctx context.Context) (*T, error) { return g.get(ctx) } +func (g *fieldgetter[T]) String() string { + return g.q.String() +} + func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] { g.q = g.q.Relation(name, apply...) return g @@ -66,5 +70,6 @@ func GetByID[T any]( tx bun.Tx, id int, ) *fieldgetter[T] { - return GetByField[T](tx, "id", id) + prefix := extractTableAlias[T]() + return GetByField[T](tx, prefix+".id", id) } diff --git a/internal/db/getlist.go b/internal/db/getlist.go index 52033ae..f999b82 100644 --- a/internal/db/getlist.go +++ b/internal/db/getlist.go @@ -3,7 +3,6 @@ package db import ( "context" "database/sql" - "fmt" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -110,7 +109,6 @@ func (l *listgetter[T]) Filter(filters ...Filter) *listgetter[T] { l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Comparator), filter.Value) } } - fmt.Println(l.q.String()) return l } diff --git a/internal/db/migrate/migrate.go b/internal/db/migrate/migrate.go index 4393e3d..c6c1aa7 100644 --- a/internal/db/migrate/migrate.go +++ b/internal/db/migrate/migrate.go @@ -498,13 +498,22 @@ func ResetDatabase(ctx context.Context, cfg *config.Config) error { conn := db.NewDB(cfg.DB) defer func() { _ = conn.Close() }() - models := conn.RegisterModels() + conn.RegisterModels() - for _, model := range models { - if err := conn.ResetModel(ctx, model); err != nil { - return errors.Wrap(err, "reset model") - } + err = RunMigrations(ctx, cfg, "rollback", "all") + if err != nil { + return errors.Wrap(err, "RunMigrations: rollback") } + err = RunMigrations(ctx, cfg, "up", "all") + if err != nil { + return errors.Wrap(err, "RunMigrations: up") + } + + // for _, model := range models { + // if err := conn.ResetModel(ctx, model); err != nil { + // return errors.Wrap(err, "reset model") + // } + // } fmt.Println("✅ Database reset complete") return nil diff --git a/internal/db/migrations/20260210182212_add_leagues.go b/internal/db/migrations/20260210182212_add_leagues.go index 53599d8..bf2de8b 100644 --- a/internal/db/migrations/20260210182212_add_leagues.go +++ b/internal/db/migrations/20260210182212_add_leagues.go @@ -55,13 +55,7 @@ func init() { if err != nil { return err } - - // Remove slap_version column from seasons table - _, err = conn.NewDropColumn(). - Model((*db.Season)(nil)). - ColumnExpr("slap_version"). - Exec(ctx) - return err + return nil }, ) } diff --git a/internal/db/migrations/20260216211155_players.go b/internal/db/migrations/20260216211155_players.go new file mode 100644 index 0000000..21a1a14 --- /dev/null +++ b/internal/db/migrations/20260216211155_players.go @@ -0,0 +1,37 @@ +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 { + // Add your migration code here + _, err := conn.NewCreateTable(). + Model((*db.Player)(nil)). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Add your rollback code here + _, err := conn.NewDropTable(). + Model((*db.Player)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + ) +} diff --git a/internal/db/migrations/20260218185128_add_type_to_seasons.go b/internal/db/migrations/20260218185128_add_type_to_seasons.go new file mode 100644 index 0000000..c396c29 --- /dev/null +++ b/internal/db/migrations/20260218185128_add_type_to_seasons.go @@ -0,0 +1,63 @@ +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 { + // Add your migration code here + _, err := conn.NewAddColumn(). + Model((*db.Season)(nil)). + IfNotExists(). + ColumnExpr("type VARCHAR NOT NULL"). + Exec(ctx) + if err != nil { + return err + } + + leagues := []db.League{ + { + Name: "Pro League", + ShortName: "Pro", + Description: "For the most experienced Slapshotters in OSL", + }, + { + Name: "Intermediate League", + ShortName: "IM", + Description: "For returning players who've been practicing in RPUGs and PUBs", + }, + { + Name: "Open League", + ShortName: "Open", + Description: "For new players just getting started with Slapshot", + }, + { + Name: "Draft League", + ShortName: "Draft", + Description: "A league where teams are selected by a draft system", + }, + } + for _, league := range leagues { + _, err = conn.NewInsert(). + Model(&league). + On("CONFLICT DO NOTHING"). + Exec(ctx) + if err != nil { + return err + } + } + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Add your rollback code here + return nil + }, + ) +} diff --git a/internal/db/migrations/20260219203524_player_names.go b/internal/db/migrations/20260219203524_player_names.go new file mode 100644 index 0000000..35dbf23 --- /dev/null +++ b/internal/db/migrations/20260219203524_player_names.go @@ -0,0 +1,31 @@ +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 { + // Add your migration code here + _, err := conn.NewAddColumn(). + Model((*db.Player)(nil)). + IfNotExists(). + ColumnExpr("name VARCHAR NOT NULL"). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Add your rollback code here + return nil + }, + ) +} diff --git a/internal/db/migrations/20260220174806_team_rosters.go b/internal/db/migrations/20260220174806_team_rosters.go new file mode 100644 index 0000000..e7e2f82 --- /dev/null +++ b/internal/db/migrations/20260220174806_team_rosters.go @@ -0,0 +1,30 @@ +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 { + // Add your migration code here + _, err := conn.NewCreateTable(). + IfNotExists(). + Model((*db.TeamRoster)(nil)). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Add your rollback code here + return nil + }, + ) +} diff --git a/internal/db/migrations/20260221103653_fixture_schedules.go b/internal/db/migrations/20260221103653_fixture_schedules.go new file mode 100644 index 0000000..c48dbb7 --- /dev/null +++ b/internal/db/migrations/20260221103653_fixture_schedules.go @@ -0,0 +1,58 @@ +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 { + _, err := conn.NewCreateTable(). + Model((*db.FixtureSchedule)(nil)). + IfNotExists(). + ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`). + ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`). + ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`). + Exec(ctx) + if err != nil { + return err + } + + // Create index on fixture_id for faster lookups + _, err = conn.NewCreateIndex(). + Model((*db.FixtureSchedule)(nil)). + Index("idx_fixture_schedules_fixture_id"). + Column("fixture_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create index on status for filtering + _, err = conn.NewCreateIndex(). + Model((*db.FixtureSchedule)(nil)). + Index("idx_fixture_schedules_status"). + Column("status"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + _, err := conn.NewDropTable(). + Model((*db.FixtureSchedule)(nil)). + IfExists(). + Exec(ctx) + return err + }, + ) +} diff --git a/internal/db/migrations/20260221140000_fixture_results.go b/internal/db/migrations/20260221140000_fixture_results.go new file mode 100644 index 0000000..071f488 --- /dev/null +++ b/internal/db/migrations/20260221140000_fixture_results.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 fixture_results table + _, err := conn.NewCreateTable(). + Model((*db.FixtureResult)(nil)). + IfNotExists(). + ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`). + ForeignKey(`("uploaded_by_user_id") REFERENCES "users" ("id")`). + Exec(ctx) + if err != nil { + return err + } + + // Create fixture_result_player_stats table + _, err = conn.NewCreateTable(). + Model((*db.FixtureResultPlayerStats)(nil)). + IfNotExists(). + ForeignKey(`("fixture_result_id") REFERENCES "fixture_results" ("id") ON DELETE CASCADE`). + ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE SET NULL`). + ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE SET NULL`). + Exec(ctx) + if err != nil { + return err + } + + // Create index on fixture_result_id for faster lookups + _, err = conn.NewCreateIndex(). + Model((*db.FixtureResultPlayerStats)(nil)). + Index("idx_frps_fixture_result_id"). + Column("fixture_result_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create index on player_id for stats queries + _, err = conn.NewCreateIndex(). + Model((*db.FixtureResultPlayerStats)(nil)). + Index("idx_frps_player_id"). + Column("player_id"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create composite index for period+team filtering + _, err = conn.NewCreateIndex(). + Model((*db.FixtureResultPlayerStats)(nil)). + Index("idx_frps_result_period_team"). + Column("fixture_result_id", "period_num", "team"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Drop fixture_result_player_stats first (has FK to fixture_results) + _, err := conn.NewDropTable(). + Model((*db.FixtureResultPlayerStats)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + + // Drop fixture_results + _, err = conn.NewDropTable(). + Model((*db.FixtureResult)(nil)). + IfExists(). + Exec(ctx) + return err + }, + ) +} 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/player.go b/internal/db/player.go new file mode 100644 index 0000000..27871cc --- /dev/null +++ b/internal/db/player.go @@ -0,0 +1,112 @@ +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, discordID string, audit *AuditMeta) (*Player, error) { + player := &Player{DiscordID: discordID} + user, err := GetUserByDiscordID(ctx, tx, discordID) + if err != nil && !IsBadRequest(err) { + return nil, errors.Wrap(err, "GetUserByDiscordID") + } + if user != nil { + player.UserID = &user.ID + } + 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.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 +} + +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 +} diff --git a/internal/db/season.go b/internal/db/season.go index ec3c572..612e170 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -25,6 +25,17 @@ const ( StatusCompleted SeasonStatus = "completed" ) +type SeasonType string + +func (s SeasonType) String() string { + return string(s) +} + +const ( + SeasonTypeRegular SeasonType = "regular" + SeasonTypeDraft SeasonType = "draft" +) + type Season struct { bun.BaseModel `bun:"table:seasons,alias:s"` @@ -36,13 +47,14 @@ type Season struct { FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"` SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"` + Type string `bun:"type,notnull" json:"type"` Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"` Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"` } // NewSeason creats a new season -func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname string, +func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname, type_ string, start time.Time, audit *AuditMeta, ) (*Season, error) { season := &Season{ @@ -50,12 +62,19 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname string, ShortName: strings.ToUpper(shortname), StartDate: start.Truncate(time.Hour * 24), SlapVersion: version, + Type: type_, } err := Insert(tx, season). WithAudit(audit, nil).Exec(ctx) if err != nil { return nil, errors.WithMessage(err, "db.Insert") } + if season.Type == SeasonTypeDraft.String() { + err = NewSeasonLeague(ctx, tx, season.ShortName, "Draft", audit) + if err != nil { + return nil, errors.Wrap(err, "NewSeasonLeague") + } + } return season, nil } diff --git a/internal/db/seasonleague.go b/internal/db/seasonleague.go index 7ab55ea..a6cc4c4 100644 --- a/internal/db/seasonleague.go +++ b/internal/db/seasonleague.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "git.haelnorr.com/h/oslstats/internal/permissions" "github.com/pkg/errors" @@ -15,8 +16,36 @@ type SeasonLeague struct { League *League `bun:"rel:belongs-to,join:league_id=id"` } -// GetSeasonLeague retrieves a specific season-league combination with teams -func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) { +// GetSeasonLeague retrieves a specific season-league combination +func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, error) { + if seasonShortName == "" { + return nil, errors.New("season short_name cannot be empty") + } + if leagueShortName == "" { + return nil, errors.New("league short_name cannot be empty") + } + + sl := new(SeasonLeague) + err := tx.NewSelect(). + Model(sl). + Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("season.short_name = ?", seasonShortName) + }). + Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("league.short_name = ?", leagueShortName) + }).Scan(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, BadRequestNotFound("season_league", "season.short_name,league.short_name", seasonShortName+","+leagueShortName) + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + + return sl, nil +} + +// GetSeasonLeagueWithTeams retrieves a specific season-league combination with teams +func GetSeasonLeagueWithTeams(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) { if seasonShortName == "" { return nil, nil, nil, errors.New("season short_name cannot be empty") } @@ -41,6 +70,9 @@ func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID). Order("t.name ASC"). + Relation("Players", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("season_id = ? AND league_id = ?", season.ID, league.ID) + }). Scan(ctx) if err != nil { return nil, nil, nil, errors.Wrap(err, "tx.Select teams") diff --git a/internal/db/setup.go b/internal/db/setup.go index e5b39e4..7d7cb68 100644 --- a/internal/db/setup.go +++ b/internal/db/setup.go @@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any { (*UserRole)(nil), (*SeasonLeague)(nil), (*TeamParticipation)(nil), + (*TeamRoster)(nil), (*User)(nil), (*DiscordToken)(nil), (*Season)(nil), @@ -33,6 +34,10 @@ func (db *DB) RegisterModels() []any { (*Permission)(nil), (*AuditLog)(nil), (*Fixture)(nil), + (*FixtureSchedule)(nil), + (*Player)(nil), + (*FixtureResult)(nil), + (*FixtureResultPlayerStats)(nil), } db.RegisterModel(models...) return models diff --git a/internal/db/team.go b/internal/db/team.go index b0d9054..52174db 100644 --- a/internal/db/team.go +++ b/internal/db/team.go @@ -17,6 +17,7 @@ type Team struct { Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"` Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"` + Players []Player `bun:"m2m:team_rosters,join:Team=Player" json:"-"` } func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) { diff --git a/internal/db/teamroster.go b/internal/db/teamroster.go new file mode 100644 index 0000000..40a6203 --- /dev/null +++ b/internal/db/teamroster.go @@ -0,0 +1,247 @@ +package db + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type TeamRoster struct { + bun.BaseModel `bun:"table:team_rosters,alias:tr"` + TeamID int `bun:",pk,notnull" json:"team_id"` + SeasonID int `bun:",pk,notnull,unique:player" json:"season_id"` + LeagueID int `bun:",pk,notnull,unique:player" json:"league_id"` + PlayerID int `bun:",pk,notnull,unique:player" json:"player_id"` + IsManager bool `bun:"is_manager,default:'false'" json:"is_manager"` + + Team *Team `bun:"rel:belongs-to,join:team_id=id" json:"-"` + Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"-"` + Season *Season `bun:"rel:belongs-to,join:season_id=id" json:"-"` + League *League `bun:"rel:belongs-to,join:league_id=id" json:"-"` +} + +type TeamWithRoster struct { + Team *Team + Season *Season + League *League + Manager *Player + Players []*Player +} + +func GetTeamRoster(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, teamID int) (*TeamWithRoster, error) { + tr := []*TeamRoster{} + err := tx.NewSelect(). + Model(&tr). + Relation("Team", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("team.id = ?", teamID) + }). + Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("season.short_name = ?", seasonShortName) + }). + Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Where("league.short_name = ?", leagueShortName) + }). + Relation("Player").Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + team, err := GetTeam(ctx, tx, teamID) + if err != nil { + return nil, errors.Wrap(err, "GetTeam") + } + sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName) + if err != nil { + return nil, errors.Wrap(err, "GetSeasonLeague") + } + var manager *Player + players := []*Player{} + for _, tp := range tr { + if tp.IsManager { + manager = tp.Player + } else { + players = append(players, tp.Player) + } + } + players = append([]*Player{manager}, players...) + twr := &TeamWithRoster{ + team, + sl.Season, + sl.League, + manager, + players, + } + return twr, nil +} + +// GetManagersByTeam returns a map of teamID -> manager Player for all teams in a season/league +func GetManagersByTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (map[int]*Player, error) { + rosters := []*TeamRoster{} + err := tx.NewSelect(). + Model(&rosters). + Where("tr.season_id = ?", seasonID). + Where("tr.league_id = ?", leagueID). + Where("tr.is_manager = true"). + Relation("Player"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + result := make(map[int]*Player, len(rosters)) + for _, r := range rosters { + result[r.TeamID] = r.Player + } + return result, nil +} + +func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, playerID int, manager bool, audit *AuditMeta) error { + season, err := GetByID[Season](tx, seasonID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetSeason") + } + league, err := GetByID[League](tx, leagueID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetLeague") + } + team, err := GetByID[Team](tx, teamID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetTeam") + } + player, err := GetByID[Player](tx, playerID).Get(ctx) + if err != nil { + return errors.Wrap(err, "GetPlayer") + } + tr := &TeamRoster{ + SeasonID: season.ID, + LeagueID: league.ID, + TeamID: team.ID, + PlayerID: player.ID, + IsManager: manager, + } + err = Insert(tx, tr).WithAudit(audit, nil).Exec(ctx) + if err != nil { + return errors.Wrap(err, "Insert") + } + return nil +} + +// 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(). + Model((*TeamRoster)(nil)). + Where("season_id = ?", seasonID). + Where("league_id = ?", leagueID). + Where("team_id = ?", teamID). + Exec(ctx) + if err != nil { + 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{ + SeasonID: seasonID, + LeagueID: leagueID, + TeamID: teamID, + PlayerID: managerID, + IsManager: true, + } + err = Insert(tx, tr).Exec(ctx) + if err != nil { + return errors.Wrap(err, "Insert manager") + } + } + + // Insert players + for _, playerID := range playerIDs { + if playerID == managerID { + continue // Already inserted as manager + } + tr := &TeamRoster{ + SeasonID: seasonID, + LeagueID: leagueID, + TeamID: teamID, + PlayerID: playerID, + IsManager: false, + } + err = Insert(tx, tr).Exec(ctx) + if err != nil { + return errors.Wrap(err, "Insert player") + } + } + + // Log the roster change + details := map[string]any{ + "season_id": seasonID, + "league_id": leagueID, + "team_id": teamID, + "manager_id": managerID, + "player_ids": playerIDs, + } + info := &AuditInfo{ + "teams.manage_players", + "team_roster", + fmt.Sprintf("%d-%d-%d", seasonID, leagueID, teamID), + details, + } + err = LogSuccess(ctx, tx, audit, info) + if err != nil { + return errors.Wrap(err, "LogSuccess") + } + + return nil +} diff --git a/internal/db/user.go b/internal/db/user.go index 0a5bb23..9b9b3d8 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -20,7 +20,8 @@ type User struct { CreatedAt int64 `bun:"created_at" json:"created_at"` DiscordID string `bun:"discord_id,unique" json:"discord_id"` - Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"` + Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"` + Player *Player `bun:"rel:has-one,join:id=user_id"` } func (u *User) GetID() int { @@ -55,7 +56,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di // GetUserByID queries the database for a user matching the given ID // Returns a BadRequestNotFound error if no user is found func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { - return GetByID[User](tx, id).Get(ctx) + return GetByID[User](tx, id).Relation("Player").Get(ctx) } // GetUserByUsername queries the database for a user matching the given username @@ -64,7 +65,7 @@ func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, if username == "" { return nil, errors.New("username not provided") } - return GetByField[User](tx, "username", username).Get(ctx) + return GetByField[User](tx, "username", username).Relation("Player").Get(ctx) } // GetUserByDiscordID queries the database for a user matching the given discord id @@ -73,7 +74,7 @@ func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User if discordID == "" { return nil, errors.New("discord_id not provided") } - return GetByField[User](tx, "discord_id", discordID).Get(ctx) + return GetByField[User](tx, "u.discord_id", discordID).Relation("Player").Get(ctx) } // GetRoles loads all the roles for this user @@ -141,7 +142,7 @@ func (u *User) IsAdmin(ctx context.Context, tx bun.Tx) (bool, error) { func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) { defaults := &PageOpts{1, 50, bun.OrderAsc, "id"} - return GetList[User](tx).GetPaged(ctx, pageOpts, defaults) + return GetList[User](tx).Relation("Player").GetPaged(ctx, pageOpts, defaults) } // GetUsersWithRoles queries the database for users with their roles preloaded diff --git a/internal/discord/config.go b/internal/discord/config.go index 11af109..e263445 100644 --- a/internal/discord/config.go +++ b/internal/discord/config.go @@ -1,3 +1,4 @@ +// Package discord provides utilities for interacting with the discord API package discord import ( @@ -13,6 +14,7 @@ type Config struct { OAuthScopes string // Authorisation scopes for OAuth RedirectPath string // ENV DISCORD_REDIRECT_PATH: Path for the OAuth redirect handler (required) BotToken string // ENV DISCORD_BOT_TOKEN: Token for the discord bot (required) + GuildID string // ENV DISCORD_GUILD_ID: ID for the discord server the bot should connect to (required) } func ConfigFromEnv() (any, error) { @@ -22,6 +24,7 @@ func ConfigFromEnv() (any, error) { OAuthScopes: getOAuthScopes(), RedirectPath: env.String("DISCORD_REDIRECT_PATH", ""), BotToken: env.String("DISCORD_BOT_TOKEN", ""), + GuildID: env.String("DISCORD_GUILD_ID", ""), } // Check required fields @@ -37,6 +40,9 @@ func ConfigFromEnv() (any, error) { if cfg.BotToken == "" { return nil, errors.New("Envar not set: DISCORD_BOT_TOKEN") } + if cfg.GuildID == "" { + return nil, errors.New("Envar not set: DISCORD_GUILD_ID") + } return cfg, nil } diff --git a/internal/discord/ratelimit.go b/internal/discord/ratelimit.go index 21f3888..6782c1c 100644 --- a/internal/discord/ratelimit.go +++ b/internal/discord/ratelimit.go @@ -19,19 +19,19 @@ type RateLimitState struct { // Do executes an HTTP request with automatic rate limit handling // It will wait if rate limits are about to be exceeded and retry once if a 429 is received -func (c *APIClient) Do(req *http.Request) (*http.Response, error) { +func (api *APIClient) Do(req *http.Request) (*http.Response, error) { if req == nil { return nil, errors.New("request cannot be nil") } // Step 1: Check if we need to wait before making request - bucket := c.getBucketFromRequest(req) - if err := c.waitIfNeeded(bucket); err != nil { + bucket := api.getBucketFromRequest(req) + if err := api.waitIfNeeded(bucket); err != nil { return nil, err } // Step 2: Execute request - resp, err := c.client.Do(req) + resp, err := api.client.Do(req) if err != nil { // Check if it's a network timeout if netErr, ok := err.(net.Error); ok && netErr.Timeout() { @@ -41,17 +41,17 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { } // Step 3: Update rate limit state from response headers - c.updateRateLimit(resp.Header) + api.updateRateLimit(resp.Header) // Step 4: Handle 429 (rate limited) if resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() // Close original response - retryAfter := c.parseRetryAfter(resp.Header) + retryAfter := api.parseRetryAfter(resp.Header) // No Retry-After header, can't retry safely if retryAfter == 0 { - c.logger.Warn(). + api.logger.Warn(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -61,7 +61,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { // Retry-After exceeds 30 second cap if retryAfter > 30*time.Second { - c.logger.Warn(). + api.logger.Warn(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { } // Wait and retry - c.logger.Warn(). + api.logger.Warn(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { time.Sleep(retryAfter) // Retry the request - resp, err = c.client.Do(req) + resp, err = api.client.Do(req) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { return nil, errors.Wrap(err, "retry request timed out") @@ -93,12 +93,12 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { } // Update rate limit again after retry - c.updateRateLimit(resp.Header) + api.updateRateLimit(resp.Header) // If STILL rate limited after retry, return error if resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() - c.logger.Error(). + api.logger.Error(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -115,15 +115,15 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { // getBucketFromRequest extracts or generates bucket ID from request // For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers -func (c *APIClient) getBucketFromRequest(req *http.Request) string { +func (api *APIClient) getBucketFromRequest(req *http.Request) string { return req.Method + ":" + req.URL.Path } // waitIfNeeded checks if we need to delay before request to avoid hitting rate limits -func (c *APIClient) waitIfNeeded(bucket string) error { - c.mu.RLock() - state, exists := c.buckets[bucket] - c.mu.RUnlock() +func (api *APIClient) waitIfNeeded(bucket string) error { + api.mu.RLock() + state, exists := api.buckets[bucket] + api.mu.RUnlock() if !exists { return nil // No state yet, proceed @@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error { waitDuration += 100 * time.Millisecond if waitDuration > 0 { - c.logger.Debug(). + api.logger.Debug(). Str("bucket", bucket). Dur("wait_duration", waitDuration). Msg("Proactively waiting for rate limit reset") @@ -150,16 +150,16 @@ func (c *APIClient) waitIfNeeded(bucket string) error { } // updateRateLimit parses response headers and updates bucket state -func (c *APIClient) updateRateLimit(headers http.Header) { +func (api *APIClient) updateRateLimit(headers http.Header) { bucket := headers.Get("X-RateLimit-Bucket") if bucket == "" { return // No bucket info, can't track } // Parse headers - limit := c.parseInt(headers.Get("X-RateLimit-Limit")) - remaining := c.parseInt(headers.Get("X-RateLimit-Remaining")) - resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After")) + limit := api.parseInt(headers.Get("X-RateLimit-Limit")) + remaining := api.parseInt(headers.Get("X-RateLimit-Remaining")) + resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After")) state := &RateLimitState{ Bucket: bucket, @@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) { Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))), } - c.mu.Lock() - c.buckets[bucket] = state - c.mu.Unlock() + api.mu.Lock() + api.buckets[bucket] = state + api.mu.Unlock() // Log rate limit state for debugging - c.logger.Debug(). + api.logger.Debug(). Str("bucket", bucket). Int("remaining", remaining). Int("limit", limit). @@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) { } // parseRetryAfter extracts retry delay from Retry-After header -func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration { +func (api *APIClient) parseRetryAfter(headers http.Header) time.Duration { retryAfter := headers.Get("Retry-After") if retryAfter == "" { return 0 } // Discord returns seconds as float - seconds := c.parseFloat(retryAfter) + seconds := api.parseFloat(retryAfter) if seconds <= 0 { return 0 } @@ -198,7 +198,7 @@ func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration { } // parseInt parses an integer from a header value, returns 0 on error -func (c *APIClient) parseInt(s string) int { +func (api *APIClient) parseInt(s string) int { if s == "" { return 0 } @@ -207,7 +207,7 @@ func (c *APIClient) parseInt(s string) int { } // parseFloat parses a float from a header value, returns 0 on error -func (c *APIClient) parseFloat(s string) float64 { +func (api *APIClient) parseFloat(s string) float64 { if s == "" { return 0 } diff --git a/internal/discord/steamid.go b/internal/discord/steamid.go new file mode 100644 index 0000000..329f0b3 --- /dev/null +++ b/internal/discord/steamid.go @@ -0,0 +1,20 @@ +package discord + +import ( + "github.com/pkg/errors" +) + +var ErrNoSteam error = errors.New("steam connection not found") + +func (s *OAuthSession) GetSteamID() (string, error) { + connections, err := s.UserConnections() + if err != nil { + return "", errors.Wrap(err, "s.UserConnections") + } + for _, conn := range connections { + if conn.Type == "steam" { + return conn.ID, nil + } + } + return "", ErrNoSteam +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 2f3e384..f22972c 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -9,6 +9,7 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --spacing: 0.25rem; + --breakpoint-md: 48rem; --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; @@ -17,6 +18,7 @@ --container-lg: 32rem; --container-2xl: 42rem; --container-3xl: 48rem; + --container-4xl: 56rem; --container-5xl: 64rem; --container-7xl: 80rem; --text-xs: 0.75rem; @@ -37,6 +39,7 @@ --text-6xl--line-height: 1; --text-9xl: 8rem; --text-9xl--line-height: 1; + --font-weight-light: 300; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; @@ -213,6 +216,9 @@ .collapse { visibility: collapse; } + .invisible { + visibility: hidden; + } .visible { visibility: visible; } @@ -260,6 +266,9 @@ .top-20 { top: calc(var(--spacing) * 20); } + .top-full { + top: 100%; + } .right-0 { right: calc(var(--spacing) * 0); } @@ -284,6 +293,12 @@ .z-50 { z-index: 50; } + .col-span-1 { + grid-column: span 1 / span 1; + } + .col-span-2 { + grid-column: span 2 / span 2; + } .container { width: 100%; @media (width >= 40rem) { @@ -308,6 +323,9 @@ .-mt-2 { margin-top: calc(var(--spacing) * -2); } + .-mt-3 { + margin-top: calc(var(--spacing) * -3); + } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -441,6 +459,9 @@ .h-16 { height: calc(var(--spacing) * 16); } + .h-\[calc\(100\%-3rem\)\] { + height: calc(100% - 3rem); + } .h-full { height: 100%; } @@ -450,6 +471,9 @@ .max-h-60 { max-height: calc(var(--spacing) * 60); } + .max-h-80 { + max-height: calc(var(--spacing) * 80); + } .max-h-96 { max-height: calc(var(--spacing) * 96); } @@ -459,6 +483,12 @@ .max-h-\[600px\] { max-height: 600px; } + .min-h-12 { + min-height: calc(var(--spacing) * 12); + } + .min-h-40 { + min-height: calc(var(--spacing) * 40); + } .min-h-48 { min-height: calc(var(--spacing) * 48); } @@ -483,21 +513,27 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-10 { + width: calc(var(--spacing) * 10); + } .w-12 { width: calc(var(--spacing) * 12); } .w-20 { width: calc(var(--spacing) * 20); } - .w-24 { - width: calc(var(--spacing) * 24); - } .w-26 { width: calc(var(--spacing) * 26); } .w-48 { width: calc(var(--spacing) * 48); } + .w-56 { + width: calc(var(--spacing) * 56); + } + .w-72 { + width: calc(var(--spacing) * 72); + } .w-80 { width: calc(var(--spacing) * 80); } @@ -513,6 +549,9 @@ .max-w-3xl { max-width: var(--container-3xl); } + .max-w-4xl { + max-width: var(--container-4xl); + } .max-w-5xl { max-width: var(--container-5xl); } @@ -528,6 +567,9 @@ .max-w-100 { max-width: calc(var(--spacing) * 100); } + .max-w-lg { + max-width: var(--container-lg); + } .max-w-md { max-width: var(--container-md); } @@ -537,6 +579,9 @@ .max-w-screen-lg { max-width: var(--breakpoint-lg); } + .max-w-screen-md { + max-width: var(--breakpoint-md); + } .max-w-screen-xl { max-width: var(--breakpoint-xl); } @@ -588,6 +633,9 @@ .cursor-grab { cursor: grab; } + .cursor-help { + cursor: help; + } .cursor-not-allowed { cursor: not-allowed; } @@ -600,12 +648,24 @@ .resize-none { resize: none; } + .list-inside { + list-style-position: inside; + } + .list-disc { + list-style-type: disc; + } + .appearance-none { + appearance: none; + } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } .grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); } @@ -621,6 +681,9 @@ .items-center { align-items: center; } + .items-end { + align-items: flex-end; + } .items-start { align-items: flex-start; } @@ -636,6 +699,9 @@ .gap-1 { gap: calc(var(--spacing) * 1); } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -651,6 +717,13 @@ .gap-8 { gap: calc(var(--spacing) * 8); } + .space-y-0\.5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -722,6 +795,14 @@ .gap-y-5 { row-gap: calc(var(--spacing) * 5); } + .divide-x { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 0; + border-inline-style: var(--tw-border-style); + border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); + border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + } + } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; @@ -801,6 +882,12 @@ .border-blue { border-color: var(--blue); } + .border-blue\/30 { + border-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--blue) 30%, transparent); + } + } .border-blue\/50 { border-color: var(--blue); @supports (color: color-mix(in lab, red, red)) { @@ -822,6 +909,9 @@ border-color: color-mix(in oklab, var(--red) 30%, transparent); } } + .border-surface0 { + border-color: var(--surface0); + } .border-surface1 { border-color: var(--surface1); } @@ -858,6 +948,12 @@ .bg-blue { background-color: var(--blue); } + .bg-blue\/5 { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 5%, transparent); + } + } .bg-blue\/20 { background-color: var(--blue); @supports (color: color-mix(in lab, red, red)) { @@ -891,6 +987,12 @@ background-color: color-mix(in oklab, var(--green) 20%, transparent); } } + .bg-green\/40 { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 40%, transparent); + } + } .bg-mantle { background-color: var(--mantle); } @@ -900,6 +1002,12 @@ .bg-peach { background-color: var(--peach); } + .bg-peach\/20 { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 20%, transparent); + } + } .bg-red { background-color: var(--red); } @@ -960,6 +1068,9 @@ .p-2\.5 { padding: calc(var(--spacing) * 2.5); } + .p-3 { + padding: calc(var(--spacing) * 3); + } .p-4 { padding: calc(var(--spacing) * 4); } @@ -969,9 +1080,15 @@ .p-8 { padding: calc(var(--spacing) * 8); } + .px-1\.5 { + padding-inline: calc(var(--spacing) * 1.5); + } .px-2 { padding-inline: calc(var(--spacing) * 2); } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -990,6 +1107,9 @@ .py-1 { padding-block: calc(var(--spacing) * 1); } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } .py-2 { padding-block: calc(var(--spacing) * 2); } @@ -1008,6 +1128,9 @@ .pt-2 { padding-top: calc(var(--spacing) * 2); } + .pt-3 { + padding-top: calc(var(--spacing) * 3); + } .pt-4 { padding-top: calc(var(--spacing) * 4); } @@ -1083,6 +1206,10 @@ --tw-leading: calc(var(--spacing) * 6); line-height: calc(var(--spacing) * 6); } + .leading-none { + --tw-leading: 1; + line-height: 1; + } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); @@ -1091,6 +1218,10 @@ --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .font-light { + --tw-font-weight: var(--font-weight-light); + font-weight: var(--font-weight-light); + } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); @@ -1141,21 +1272,51 @@ .text-overlay0 { color: var(--overlay0); } + .text-peach { + color: var(--peach); + } .text-red { color: var(--red); } + .text-red\/60 { + color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--red) 60%, transparent); + } + } + .text-red\/80 { + color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--red) 80%, transparent); + } + } .text-subtext0 { color: var(--subtext0); } .text-subtext1 { color: var(--subtext1); } + .text-teal { + color: var(--teal); + } .text-text { color: var(--text); } .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)) { + color: color-mix(in oklab, var(--yellow) 70%, transparent); + } + } .text-yellow\/80 { color: var(--yellow); @supports (color: color-mix(in lab, red, red)) { @@ -1174,6 +1335,11 @@ .italic { font-style: italic; } + .placeholder-subtext0 { + &::placeholder { + color: var(--subtext0); + } + } .opacity-0 { opacity: 0%; } @@ -1187,6 +1353,10 @@ --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -1250,6 +1420,83 @@ -webkit-user-select: none; user-select: none; } + .group-hover\:visible { + &:is(:where(.group):hover *) { + @media (hover: hover) { + visibility: visible; + } + } + } + .group-hover\:opacity-100 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 100%; + } + } + } + .file\:mr-4 { + &::file-selector-button { + margin-right: calc(var(--spacing) * 4); + } + } + .file\:rounded { + &::file-selector-button { + border-radius: 0.25rem; + } + } + .file\:border-0 { + &::file-selector-button { + border-style: var(--tw-border-style); + border-width: 0px; + } + } + .file\:bg-blue { + &::file-selector-button { + background-color: var(--blue); + } + } + .file\:px-3 { + &::file-selector-button { + padding-inline: calc(var(--spacing) * 3); + } + } + .file\:py-1 { + &::file-selector-button { + padding-block: calc(var(--spacing) * 1); + } + } + .file\:text-sm { + &::file-selector-button { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + } + .file\:font-medium { + &::file-selector-button { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + } + .file\:text-mantle { + &::file-selector-button { + color: var(--mantle); + } + } + .file\:transition { + &::file-selector-button { + 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)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + } + .hover\:-translate-y-0\.5 { + &:hover { + @media (hover: hover) { + --tw-translate-y: calc(var(--spacing) * -0.5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + } .hover\:cursor-pointer { &:hover { @media (hover: hover) { @@ -1325,6 +1572,26 @@ } } } + .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) { + background-color: var(--peach); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--peach) 80%, transparent); + } + } + } + } .hover\:bg-red\/25 { &:hover { @media (hover: hover) { @@ -1335,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) { @@ -1420,6 +1697,16 @@ } } } + .hover\:bg-yellow\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--yellow) 80%, transparent); + } + } + } + } .hover\:text-blue { &:hover { @media (hover: hover) { @@ -1495,6 +1782,35 @@ } } } + .hover\:shadow-lg { + &:hover { + @media (hover: hover) { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + } + .file\:hover\:cursor-pointer { + &::file-selector-button { + &:hover { + @media (hover: hover) { + cursor: pointer; + } + } + } + } + .file\:hover\:bg-blue\/80 { + &::file-selector-button { + &:hover { + @media (hover: hover) { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 80%, transparent); + } + } + } + } + } .focus\:border-blue { &:focus { border-color: var(--blue); @@ -1576,11 +1892,25 @@ } } } + .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%; } } + .disabled\:shadow-none { + &:disabled { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } .sm\:end-6 { @media (width >= 40rem) { inset-inline-end: calc(var(--spacing) * 6); @@ -1668,6 +1998,11 @@ scale: var(--tw-scale-x) var(--tw-scale-y); } } + .sm\:grid-cols-4 { + @media (width >= 40rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } .sm\:flex-row { @media (width >= 40rem) { flex-direction: row; @@ -1789,11 +2124,21 @@ display: inline; } } + .lg\:grid-cols-2 { + @media (width >= 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } .lg\:grid-cols-3 { @media (width >= 64rem) { grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .lg\:grid-cols-6 { + @media (width >= 64rem) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; @@ -2029,7 +2374,7 @@ inherits: false; initial-value: 0; } -@property --tw-divide-y-reverse { +@property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; @@ -2039,6 +2384,11 @@ inherits: false; initial-value: solid; } +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-leading { syntax: "*"; inherits: false; @@ -2198,8 +2548,9 @@ --tw-skew-y: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; - --tw-divide-y-reverse: 0; + --tw-divide-x-reverse: 0; --tw-border-style: solid; + --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-tracking: initial; diff --git a/internal/embedfs/web/js/localtime.js b/internal/embedfs/web/js/localtime.js new file mode 100644 index 0000000..38a233c --- /dev/null +++ b/internal/embedfs/web/js/localtime.js @@ -0,0 +1,84 @@ +// localtime.js - Converts UTC