Compare commits
21 Commits
3b430d39e2
...
d0c1b53d87
| Author | SHA1 | Date | |
|---|---|---|---|
| d0c1b53d87 | |||
| 3c866551a4 | |||
| 4185ab58e2 | |||
| 1c93a707ab | |||
| 680ba3fe50 | |||
| 6439bf782b | |||
| 971960d0cb | |||
| 9e3355deb6 | |||
| 7ea21c63e4 | |||
| c3d8e6c675 | |||
| 71373c4747 | |||
| 667c9f04a7 | |||
| 4b81ac12cf | |||
| 52a168f1aa | |||
| 7d5949af1e | |||
| 9db855f45b | |||
| 2db24c3f77 | |||
| 42282d05b1 | |||
| 9362448f22 | |||
| f8090aa0cc | |||
| bb3bed3e89 |
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/embedfs"
|
"git.haelnorr.com/h/oslstats/internal/embedfs"
|
||||||
"git.haelnorr.com/h/oslstats/internal/server"
|
"git.haelnorr.com/h/oslstats/internal/server"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initializes and runs the server
|
// 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")
|
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")
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "setupHttpServer")
|
return errors.Wrap(err, "setupHttpServer")
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/uptrace/bun v1.2.16
|
github.com/uptrace/bun v1.2.16
|
||||||
github.com/uptrace/bun/dialect/pgdialect v1.2.16
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16
|
||||||
github.com/uptrace/bun/driver/pgdriver v1.2.16
|
github.com/uptrace/bun/driver/pgdriver v1.2.16
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
2
go.sum
2
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/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/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/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=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/pkg/oauth"
|
"git.haelnorr.com/h/oslstats/pkg/oauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,7 @@ type Config struct {
|
|||||||
Discord *discord.Config
|
Discord *discord.Config
|
||||||
OAuth *oauth.Config
|
OAuth *oauth.Config
|
||||||
RBAC *rbac.Config
|
RBAC *rbac.Config
|
||||||
|
Slapshot *slapshotapi.Config
|
||||||
Flags *Flags
|
Flags *Flags
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
|||||||
discord.NewEZConfIntegration(),
|
discord.NewEZConfIntegration(),
|
||||||
oauth.NewEZConfIntegration(),
|
oauth.NewEZConfIntegration(),
|
||||||
rbac.NewEZConfIntegration(),
|
rbac.NewEZConfIntegration(),
|
||||||
|
slapshotapi.NewEZConfIntegration(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations")
|
return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations")
|
||||||
@@ -93,6 +96,11 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
|||||||
return nil, nil, errors.New("RBAC Config not loaded")
|
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{
|
config := &Config{
|
||||||
DB: dbcfg.(*db.Config),
|
DB: dbcfg.(*db.Config),
|
||||||
HWS: hwscfg.(*hws.Config),
|
HWS: hwscfg.(*hws.Config),
|
||||||
@@ -101,6 +109,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
|||||||
Discord: discordcfg.(*discord.Config),
|
Discord: discordcfg.(*discord.Config),
|
||||||
OAuth: oauthcfg.(*oauth.Config),
|
OAuth: oauthcfg.(*oauth.Config),
|
||||||
RBAC: rbaccfg.(*rbac.Config),
|
RBAC: rbaccfg.(*rbac.Config),
|
||||||
|
Slapshot: slapcfg.(*slapshotapi.Config),
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuditMeta struct {
|
type AuditMeta struct {
|
||||||
r *http.Request
|
ipAddress string
|
||||||
|
userAgent string
|
||||||
u *User
|
u *User
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudit(r *http.Request, u *User) *AuditMeta {
|
func NewAudit(ipAdd, agent string, user *User) *AuditMeta {
|
||||||
if u == nil {
|
return &AuditMeta{ipAdd, agent, user}
|
||||||
u = CurrentUser(r.Context())
|
}
|
||||||
}
|
|
||||||
return &AuditMeta{r, u}
|
func NewAuditFromRequest(r *http.Request) *AuditMeta {
|
||||||
|
u := CurrentUser(r.Context())
|
||||||
|
return &AuditMeta{r.RemoteAddr, r.UserAgent(), u}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditInfo contains metadata for audit logging
|
// AuditInfo contains metadata for audit logging
|
||||||
@@ -45,9 +48,44 @@ func extractTableName[T any]() string {
|
|||||||
if bunTag != "" {
|
if bunTag != "" {
|
||||||
// Parse tag: "table:seasons,alias:s" -> "seasons"
|
// Parse tag: "table:seasons,alias:s" -> "seasons"
|
||||||
for part := range strings.SplitSeq(bunTag, ",") {
|
for part := range strings.SplitSeq(bunTag, ",") {
|
||||||
part, _ := strings.CutPrefix(part, "table:")
|
part, match := strings.CutPrefix(part, "table:")
|
||||||
|
if match {
|
||||||
return part
|
return part
|
||||||
}
|
}
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use struct name in lowercase + "s"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
@@ -78,7 +77,6 @@ func (a *AuditLogFilter) UserIDs(ids []int) *AuditLogFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuditLogFilter) Actions(actions []string) *AuditLogFilter {
|
func (a *AuditLogFilter) Actions(actions []string) *AuditLogFilter {
|
||||||
fmt.Println(actions)
|
|
||||||
if len(actions) > 0 {
|
if len(actions) > 0 {
|
||||||
a.In("al.action", actions)
|
a.In("al.action", actions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ func log(
|
|||||||
if meta.u == nil {
|
if meta.u == nil {
|
||||||
return errors.New("user cannot be nil for audit logging")
|
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
|
// Convert resourceID to string
|
||||||
var resourceIDStr *string
|
var resourceIDStr *string
|
||||||
@@ -70,18 +67,14 @@ func log(
|
|||||||
detailsJSON = jsonBytes
|
detailsJSON = jsonBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract IP and User-Agent from request
|
|
||||||
ipAddress := meta.r.RemoteAddr
|
|
||||||
userAgent := meta.r.UserAgent()
|
|
||||||
|
|
||||||
log := &AuditLog{
|
log := &AuditLog{
|
||||||
UserID: meta.u.ID,
|
UserID: meta.u.ID,
|
||||||
Action: info.Action,
|
Action: info.Action,
|
||||||
ResourceType: info.ResourceType,
|
ResourceType: info.ResourceType,
|
||||||
ResourceID: resourceIDStr,
|
ResourceID: resourceIDStr,
|
||||||
Details: detailsJSON,
|
Details: detailsJSON,
|
||||||
IPAddress: ipAddress,
|
IPAddress: meta.ipAddress,
|
||||||
UserAgent: userAgent,
|
UserAgent: meta.userAgent,
|
||||||
Result: result,
|
Result: result,
|
||||||
ErrorMessage: errorMessage,
|
ErrorMessage: errorMessage,
|
||||||
CreatedAt: time.Now().Unix(),
|
CreatedAt: time.Now().Unix(),
|
||||||
|
|||||||
@@ -28,12 +28,41 @@ type Fixture struct {
|
|||||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
||||||
AwayTeam *Team `bun:"rel:belongs-to,join:away_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,
|
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
||||||
homeTeamID, awayTeamID, round int, audit *AuditMeta,
|
homeTeamID, awayTeamID, round int, audit *AuditMeta,
|
||||||
) (*Fixture, error) {
|
) (*Fixture, error) {
|
||||||
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "GetSeasonLeague")
|
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,
|
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
||||||
round int, audit *AuditMeta,
|
round int, audit *AuditMeta,
|
||||||
) ([]*Fixture, error) {
|
) ([]*Fixture, error) {
|
||||||
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
season, league, teams, err := GetSeasonLeagueWithTeams(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "GetSeasonLeague")
|
return nil, errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
@@ -71,22 +100,22 @@ func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName s
|
|||||||
return fixtures, nil
|
return fixtures, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) {
|
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, []*Fixture, error) {
|
||||||
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
return nil, nil, errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
fixtures, err := GetList[Fixture](tx).
|
fixtures, err := GetList[Fixture](tx).
|
||||||
Where("season_id = ?", season.ID).
|
Where("season_id = ?", sl.SeasonID).
|
||||||
Where("league_id = ?", league.ID).
|
Where("league_id = ?", sl.LeagueID).
|
||||||
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
|
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
|
||||||
Relation("HomeTeam").
|
Relation("HomeTeam").
|
||||||
Relation("AwayTeam").
|
Relation("AwayTeam").
|
||||||
GetAll(ctx)
|
GetAll(ctx)
|
||||||
if err != nil {
|
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) {
|
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)
|
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) {
|
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
|
||||||
fixtures, err := GetList[Fixture](tx).
|
fixtures, err := GetList[Fixture](tx).
|
||||||
Where("season_id = ?", seasonID).
|
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 {
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "GetSeasonLeague")
|
return errors.Wrap(err, "GetSeasonLeague")
|
||||||
}
|
}
|
||||||
err = DeleteItem[Fixture](tx).
|
err = DeleteItem[Fixture](tx).
|
||||||
Where("season_id = ?", season.ID).
|
Where("season_id = ?", sl.SeasonID).
|
||||||
Where("league_id = ?", league.ID).
|
Where("league_id = ?", sl.LeagueID).
|
||||||
WithAudit(audit, nil).
|
WithAudit(audit, nil).
|
||||||
Delete(ctx)
|
Delete(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -269,7 +330,7 @@ func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
|
|||||||
matchups := make([]*versus, len(teams))
|
matchups := make([]*versus, len(teams))
|
||||||
for i, opponent := range teams {
|
for i, opponent := range teams {
|
||||||
versus := &versus{}
|
versus := &versus{}
|
||||||
if i%2+round%2 == 0 {
|
if (i+round)%2 == 0 {
|
||||||
versus.homeTeam = team
|
versus.homeTeam = team
|
||||||
versus.awayTeam = opponent
|
versus.awayTeam = opponent
|
||||||
} else {
|
} else {
|
||||||
@@ -280,3 +341,40 @@ func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
|
|||||||
}
|
}
|
||||||
return matchups
|
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
|
||||||
|
}
|
||||||
|
|||||||
587
internal/db/fixture_result.go
Normal file
587
internal/db/fixture_result.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
426
internal/db/fixture_schedule.go
Normal file
426
internal/db/fixture_schedule.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
342
internal/db/freeagent.go
Normal file
342
internal/db/freeagent.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeasonLeagueFreeAgent tracks players registered as free agents in a season_league.
|
||||||
|
type SeasonLeagueFreeAgent struct {
|
||||||
|
bun.BaseModel `bun:"table:season_league_free_agents,alias:slfa"`
|
||||||
|
|
||||||
|
SeasonID int `bun:",pk,notnull"`
|
||||||
|
LeagueID int `bun:",pk,notnull"`
|
||||||
|
PlayerID int `bun:",pk,notnull"`
|
||||||
|
RegisteredAt int64 `bun:",notnull"`
|
||||||
|
RegisteredByUserID int `bun:",notnull"`
|
||||||
|
|
||||||
|
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
|
||||||
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
|
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||||
|
RegisteredBy *User `bun:"rel:belongs-to,join:registered_by_user_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixtureFreeAgent tracks which free agents are nominated for specific fixtures.
|
||||||
|
type FixtureFreeAgent struct {
|
||||||
|
bun.BaseModel `bun:"table:fixture_free_agents,alias:ffa"`
|
||||||
|
|
||||||
|
FixtureID int `bun:",pk,notnull"`
|
||||||
|
PlayerID int `bun:",pk,notnull"`
|
||||||
|
TeamID int `bun:",notnull"`
|
||||||
|
NominatedByUserID int `bun:",notnull"`
|
||||||
|
NominatedAt int64 `bun:",notnull"`
|
||||||
|
|
||||||
|
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||||
|
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||||
|
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
|
||||||
|
NominatedBy *User `bun:"rel:belongs-to,join:nominated_by_user_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFreeAgent registers a player as a free agent in a season_league.
|
||||||
|
func RegisterFreeAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID, playerID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
user := CurrentUser(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &SeasonLeagueFreeAgent{
|
||||||
|
SeasonID: seasonID,
|
||||||
|
LeagueID: leagueID,
|
||||||
|
PlayerID: playerID,
|
||||||
|
RegisteredAt: time.Now().Unix(),
|
||||||
|
RegisteredByUserID: user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.add",
|
||||||
|
ResourceType: "season_league_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"player_id": playerID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterFreeAgent removes a player's free agent registration and all their nominations.
|
||||||
|
func UnregisterFreeAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID, playerID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
// First remove all nominations for this player
|
||||||
|
err := RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then remove the registration
|
||||||
|
_, err = tx.NewDelete().
|
||||||
|
Model((*SeasonLeagueFreeAgent)(nil)).
|
||||||
|
Where("season_id = ?", seasonID).
|
||||||
|
Where("league_id = ?", leagueID).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.remove",
|
||||||
|
ResourceType: "season_league_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"player_id": playerID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = LogSuccess(ctx, tx, audit, info)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "LogSuccess")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFreeAgentsForSeasonLeague returns all players registered as free agents in a season_league.
|
||||||
|
func GetFreeAgentsForSeasonLeague(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) ([]*SeasonLeagueFreeAgent, error) {
|
||||||
|
entries := []*SeasonLeagueFreeAgent{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&entries).
|
||||||
|
Where("slfa.season_id = ?", seasonID).
|
||||||
|
Where("slfa.league_id = ?", leagueID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Relation("RegisteredBy").
|
||||||
|
Order("slfa.registered_at ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFreeAgentRegistered checks if a player is registered as a free agent in a season_league.
|
||||||
|
func IsFreeAgentRegistered(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID, playerID int,
|
||||||
|
) (bool, error) {
|
||||||
|
count, err := tx.NewSelect().
|
||||||
|
Model((*SeasonLeagueFreeAgent)(nil)).
|
||||||
|
Where("season_id = ?", seasonID).
|
||||||
|
Where("league_id = ?", leagueID).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NominateFreeAgent nominates a free agent for a specific fixture on behalf of a team.
|
||||||
|
func NominateFreeAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID, playerID, teamID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
user := CurrentUser(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already nominated by another team
|
||||||
|
existing := new(FixtureFreeAgent)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(existing).
|
||||||
|
Where("ffa.fixture_id = ?", fixtureID).
|
||||||
|
Where("ffa.player_id = ?", playerID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err == nil {
|
||||||
|
// Found existing nomination
|
||||||
|
if existing.TeamID != teamID {
|
||||||
|
return BadRequest("Player already nominated for this fixture by another team")
|
||||||
|
}
|
||||||
|
return BadRequest("Player already nominated for this fixture")
|
||||||
|
}
|
||||||
|
if err.Error() != "sql: no rows in result set" {
|
||||||
|
return errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max 2 free agents per team per fixture
|
||||||
|
count, err := tx.NewSelect().
|
||||||
|
Model((*FixtureFreeAgent)(nil)).
|
||||||
|
Where("fixture_id = ?", fixtureID).
|
||||||
|
Where("team_id = ?", teamID).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewSelect count")
|
||||||
|
}
|
||||||
|
if count >= 2 {
|
||||||
|
return BadRequest("Maximum of 2 free agents per team per fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &FixtureFreeAgent{
|
||||||
|
FixtureID: fixtureID,
|
||||||
|
PlayerID: playerID,
|
||||||
|
TeamID: teamID,
|
||||||
|
NominatedByUserID: user.ID,
|
||||||
|
NominatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.nominate",
|
||||||
|
ResourceType: "fixture_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"fixture_id": fixtureID,
|
||||||
|
"player_id": playerID,
|
||||||
|
"team_id": teamID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNominatedFreeAgents returns all free agents nominated for a fixture.
|
||||||
|
func GetNominatedFreeAgents(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID int,
|
||||||
|
) ([]*FixtureFreeAgent, error) {
|
||||||
|
entries := []*FixtureFreeAgent{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&entries).
|
||||||
|
Where("ffa.fixture_id = ?", fixtureID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Relation("Team").
|
||||||
|
Relation("NominatedBy").
|
||||||
|
Order("ffa.nominated_at ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNominatedFreeAgentsByTeam returns free agents nominated by a specific team for a fixture.
|
||||||
|
func GetNominatedFreeAgentsByTeam(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID, teamID int,
|
||||||
|
) ([]*FixtureFreeAgent, error) {
|
||||||
|
entries := []*FixtureFreeAgent{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&entries).
|
||||||
|
Where("ffa.fixture_id = ?", fixtureID).
|
||||||
|
Where("ffa.team_id = ?", teamID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Order("ffa.nominated_at ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAllFreeAgentNominationsForPlayer deletes all nominations for a player.
|
||||||
|
// Used for cascade deletion on team join and unregister.
|
||||||
|
func RemoveAllFreeAgentNominationsForPlayer(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
playerID int,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*FixtureFreeAgent)(nil)).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFreeAgentNomination removes a specific nomination.
|
||||||
|
func RemoveFreeAgentNomination(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID, playerID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*FixtureFreeAgent)(nil)).
|
||||||
|
Where("fixture_id = ?", fixtureID).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.remove_nomination",
|
||||||
|
ResourceType: "fixture_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"fixture_id": fixtureID,
|
||||||
|
"player_id": playerID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = LogSuccess(ctx, tx, audit, info)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "LogSuccess")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFreeAgentRegistrationForPlayer removes all free agent registrations for a player.
|
||||||
|
// Used on team join.
|
||||||
|
func RemoveFreeAgentRegistrationForPlayer(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
playerID int,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*SeasonLeagueFreeAgent)(nil)).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ func (g *fieldgetter[T]) Get(ctx context.Context) (*T, error) {
|
|||||||
return g.get(ctx)
|
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] {
|
func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] {
|
||||||
g.q = g.q.Relation(name, apply...)
|
g.q = g.q.Relation(name, apply...)
|
||||||
return g
|
return g
|
||||||
@@ -66,5 +70,6 @@ func GetByID[T any](
|
|||||||
tx bun.Tx,
|
tx bun.Tx,
|
||||||
id int,
|
id int,
|
||||||
) *fieldgetter[T] {
|
) *fieldgetter[T] {
|
||||||
return GetByField[T](tx, "id", id)
|
prefix := extractTableAlias[T]()
|
||||||
|
return GetByField[T](tx, prefix+".id", id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"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)
|
l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Comparator), filter.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println(l.q.String())
|
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,14 +498,23 @@ func ResetDatabase(ctx context.Context, cfg *config.Config) error {
|
|||||||
conn := db.NewDB(cfg.DB)
|
conn := db.NewDB(cfg.DB)
|
||||||
defer func() { _ = conn.Close() }()
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
models := conn.RegisterModels()
|
conn.RegisterModels()
|
||||||
|
|
||||||
for _, model := range models {
|
err = RunMigrations(ctx, cfg, "rollback", "all")
|
||||||
if err := conn.ResetModel(ctx, model); err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "reset model")
|
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")
|
fmt.Println("✅ Database reset complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,13 +55,7 @@ func init() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
// Remove slap_version column from seasons table
|
|
||||||
_, err = conn.NewDropColumn().
|
|
||||||
Model((*db.Season)(nil)).
|
|
||||||
ColumnExpr("slap_version").
|
|
||||||
Exec(ctx)
|
|
||||||
return err
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
37
internal/db/migrations/20260216211155_players.go
Normal file
37
internal/db/migrations/20260216211155_players.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
63
internal/db/migrations/20260218185128_add_type_to_seasons.go
Normal file
63
internal/db/migrations/20260218185128_add_type_to_seasons.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
31
internal/db/migrations/20260219203524_player_names.go
Normal file
31
internal/db/migrations/20260219203524_player_names.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
30
internal/db/migrations/20260220174806_team_rosters.go
Normal file
30
internal/db/migrations/20260220174806_team_rosters.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
91
internal/db/migrations/20260221140000_fixture_results.go
Normal file
91
internal/db/migrations/20260221140000_fixture_results.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Migrations.MustRegister(
|
||||||
|
// UP migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Create 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
91
internal/db/migrations/20260222140000_free_agents.go
Normal file
91
internal/db/migrations/20260222140000_free_agents.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Migrations.MustRegister(
|
||||||
|
// UP migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Create season_league_free_agents table
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
Model((*db.SeasonLeagueFreeAgent)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
ForeignKey(`("season_id") REFERENCES "seasons" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("league_id") REFERENCES "leagues" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("registered_by_user_id") REFERENCES "users" ("id")`).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fixture_free_agents table
|
||||||
|
_, err = conn.NewCreateTable().
|
||||||
|
Model((*db.FixtureFreeAgent)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("nominated_by_user_id") REFERENCES "users" ("id")`).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on fixture_free_agents for team lookups
|
||||||
|
_, err = conn.NewCreateIndex().
|
||||||
|
Model((*db.FixtureFreeAgent)(nil)).
|
||||||
|
Index("idx_ffa_fixture_team").
|
||||||
|
Column("fixture_id", "team_id").
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add is_free_agent column to fixture_result_player_stats
|
||||||
|
_, err = conn.NewAddColumn().
|
||||||
|
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||||
|
ColumnExpr("is_free_agent BOOLEAN NOT NULL DEFAULT false").
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Drop is_free_agent column from fixture_result_player_stats
|
||||||
|
_, err := conn.NewDropColumn().
|
||||||
|
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||||
|
ColumnExpr("is_free_agent").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop fixture_free_agents table
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.FixtureFreeAgent)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop season_league_free_agents table
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.SeasonLeagueFreeAgent)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Exec(ctx)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
112
internal/db/player.go
Normal file
112
internal/db/player.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -25,6 +25,17 @@ const (
|
|||||||
StatusCompleted SeasonStatus = "completed"
|
StatusCompleted SeasonStatus = "completed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SeasonType string
|
||||||
|
|
||||||
|
func (s SeasonType) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeasonTypeRegular SeasonType = "regular"
|
||||||
|
SeasonTypeDraft SeasonType = "draft"
|
||||||
|
)
|
||||||
|
|
||||||
type Season struct {
|
type Season struct {
|
||||||
bun.BaseModel `bun:"table:seasons,alias:s"`
|
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"`
|
FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"`
|
||||||
FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
|
FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
|
||||||
SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"`
|
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:"-"`
|
Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"`
|
||||||
Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
|
Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSeason creats a new season
|
// 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,
|
start time.Time, audit *AuditMeta,
|
||||||
) (*Season, error) {
|
) (*Season, error) {
|
||||||
season := &Season{
|
season := &Season{
|
||||||
@@ -50,12 +62,19 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname string,
|
|||||||
ShortName: strings.ToUpper(shortname),
|
ShortName: strings.ToUpper(shortname),
|
||||||
StartDate: start.Truncate(time.Hour * 24),
|
StartDate: start.Truncate(time.Hour * 24),
|
||||||
SlapVersion: version,
|
SlapVersion: version,
|
||||||
|
Type: type_,
|
||||||
}
|
}
|
||||||
err := Insert(tx, season).
|
err := Insert(tx, season).
|
||||||
WithAudit(audit, nil).Exec(ctx)
|
WithAudit(audit, nil).Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithMessage(err, "db.Insert")
|
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
|
return season, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -15,8 +16,36 @@ type SeasonLeague struct {
|
|||||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSeasonLeague retrieves a specific season-league combination with teams
|
// GetSeasonLeague retrieves a specific season-league combination
|
||||||
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
|
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 == "" {
|
if seasonShortName == "" {
|
||||||
return nil, nil, nil, errors.New("season short_name cannot be empty")
|
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").
|
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).
|
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
|
||||||
Order("t.name ASC").
|
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)
|
Scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
|
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func (db *DB) RegisterModels() []any {
|
|||||||
(*UserRole)(nil),
|
(*UserRole)(nil),
|
||||||
(*SeasonLeague)(nil),
|
(*SeasonLeague)(nil),
|
||||||
(*TeamParticipation)(nil),
|
(*TeamParticipation)(nil),
|
||||||
|
(*TeamRoster)(nil),
|
||||||
(*User)(nil),
|
(*User)(nil),
|
||||||
(*DiscordToken)(nil),
|
(*DiscordToken)(nil),
|
||||||
(*Season)(nil),
|
(*Season)(nil),
|
||||||
@@ -33,6 +34,10 @@ func (db *DB) RegisterModels() []any {
|
|||||||
(*Permission)(nil),
|
(*Permission)(nil),
|
||||||
(*AuditLog)(nil),
|
(*AuditLog)(nil),
|
||||||
(*Fixture)(nil),
|
(*Fixture)(nil),
|
||||||
|
(*FixtureSchedule)(nil),
|
||||||
|
(*Player)(nil),
|
||||||
|
(*FixtureResult)(nil),
|
||||||
|
(*FixtureResultPlayerStats)(nil),
|
||||||
}
|
}
|
||||||
db.RegisterModel(models...)
|
db.RegisterModel(models...)
|
||||||
return models
|
return models
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Team struct {
|
|||||||
|
|
||||||
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
|
Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
|
||||||
Leagues []League `bun:"m2m:team_participations,join:Team=League" 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) {
|
func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {
|
||||||
|
|||||||
247
internal/db/teamroster.go
Normal file
247
internal/db/teamroster.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type User struct {
|
|||||||
DiscordID string `bun:"discord_id,unique" json:"discord_id"`
|
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 {
|
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
|
// GetUserByID queries the database for a user matching the given ID
|
||||||
// Returns a BadRequestNotFound error if no user is found
|
// Returns a BadRequestNotFound error if no user is found
|
||||||
func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
|
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
|
// 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 == "" {
|
if username == "" {
|
||||||
return nil, errors.New("username not provided")
|
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
|
// 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 == "" {
|
if discordID == "" {
|
||||||
return nil, errors.New("discord_id not provided")
|
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
|
// 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) {
|
func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) {
|
||||||
defaults := &PageOpts{1, 50, bun.OrderAsc, "id"}
|
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
|
// GetUsersWithRoles queries the database for users with their roles preloaded
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package discord provides utilities for interacting with the discord API
|
||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +14,7 @@ type Config struct {
|
|||||||
OAuthScopes string // Authorisation scopes for OAuth
|
OAuthScopes string // Authorisation scopes for OAuth
|
||||||
RedirectPath string // ENV DISCORD_REDIRECT_PATH: Path for the OAuth redirect handler (required)
|
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)
|
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) {
|
func ConfigFromEnv() (any, error) {
|
||||||
@@ -22,6 +24,7 @@ func ConfigFromEnv() (any, error) {
|
|||||||
OAuthScopes: getOAuthScopes(),
|
OAuthScopes: getOAuthScopes(),
|
||||||
RedirectPath: env.String("DISCORD_REDIRECT_PATH", ""),
|
RedirectPath: env.String("DISCORD_REDIRECT_PATH", ""),
|
||||||
BotToken: env.String("DISCORD_BOT_TOKEN", ""),
|
BotToken: env.String("DISCORD_BOT_TOKEN", ""),
|
||||||
|
GuildID: env.String("DISCORD_GUILD_ID", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check required fields
|
// Check required fields
|
||||||
@@ -37,6 +40,9 @@ func ConfigFromEnv() (any, error) {
|
|||||||
if cfg.BotToken == "" {
|
if cfg.BotToken == "" {
|
||||||
return nil, errors.New("Envar not set: DISCORD_BOT_TOKEN")
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,19 +19,19 @@ type RateLimitState struct {
|
|||||||
|
|
||||||
// Do executes an HTTP request with automatic rate limit handling
|
// 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
|
// 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 {
|
if req == nil {
|
||||||
return nil, errors.New("request cannot be nil")
|
return nil, errors.New("request cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if we need to wait before making request
|
// Step 1: Check if we need to wait before making request
|
||||||
bucket := c.getBucketFromRequest(req)
|
bucket := api.getBucketFromRequest(req)
|
||||||
if err := c.waitIfNeeded(bucket); err != nil {
|
if err := api.waitIfNeeded(bucket); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Execute request
|
// Step 2: Execute request
|
||||||
resp, err := c.client.Do(req)
|
resp, err := api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if it's a network timeout
|
// Check if it's a network timeout
|
||||||
if netErr, ok := err.(net.Error); ok && netErr.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
|
// Step 3: Update rate limit state from response headers
|
||||||
c.updateRateLimit(resp.Header)
|
api.updateRateLimit(resp.Header)
|
||||||
|
|
||||||
// Step 4: Handle 429 (rate limited)
|
// Step 4: Handle 429 (rate limited)
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
resp.Body.Close() // Close original response
|
resp.Body.Close() // Close original response
|
||||||
|
|
||||||
retryAfter := c.parseRetryAfter(resp.Header)
|
retryAfter := api.parseRetryAfter(resp.Header)
|
||||||
|
|
||||||
// No Retry-After header, can't retry safely
|
// No Retry-After header, can't retry safely
|
||||||
if retryAfter == 0 {
|
if retryAfter == 0 {
|
||||||
c.logger.Warn().
|
api.logger.Warn().
|
||||||
Str("bucket", bucket).
|
Str("bucket", bucket).
|
||||||
Str("method", req.Method).
|
Str("method", req.Method).
|
||||||
Str("path", req.URL.Path).
|
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
|
// Retry-After exceeds 30 second cap
|
||||||
if retryAfter > 30*time.Second {
|
if retryAfter > 30*time.Second {
|
||||||
c.logger.Warn().
|
api.logger.Warn().
|
||||||
Str("bucket", bucket).
|
Str("bucket", bucket).
|
||||||
Str("method", req.Method).
|
Str("method", req.Method).
|
||||||
Str("path", req.URL.Path).
|
Str("path", req.URL.Path).
|
||||||
@@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait and retry
|
// Wait and retry
|
||||||
c.logger.Warn().
|
api.logger.Warn().
|
||||||
Str("bucket", bucket).
|
Str("bucket", bucket).
|
||||||
Str("method", req.Method).
|
Str("method", req.Method).
|
||||||
Str("path", req.URL.Path).
|
Str("path", req.URL.Path).
|
||||||
@@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
|
|||||||
time.Sleep(retryAfter)
|
time.Sleep(retryAfter)
|
||||||
|
|
||||||
// Retry the request
|
// Retry the request
|
||||||
resp, err = c.client.Do(req)
|
resp, err = api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
return nil, errors.Wrap(err, "retry request timed out")
|
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
|
// Update rate limit again after retry
|
||||||
c.updateRateLimit(resp.Header)
|
api.updateRateLimit(resp.Header)
|
||||||
|
|
||||||
// If STILL rate limited after retry, return error
|
// If STILL rate limited after retry, return error
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
c.logger.Error().
|
api.logger.Error().
|
||||||
Str("bucket", bucket).
|
Str("bucket", bucket).
|
||||||
Str("method", req.Method).
|
Str("method", req.Method).
|
||||||
Str("path", req.URL.Path).
|
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
|
// getBucketFromRequest extracts or generates bucket ID from request
|
||||||
// For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers
|
// 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
|
return req.Method + ":" + req.URL.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitIfNeeded checks if we need to delay before request to avoid hitting rate limits
|
// waitIfNeeded checks if we need to delay before request to avoid hitting rate limits
|
||||||
func (c *APIClient) waitIfNeeded(bucket string) error {
|
func (api *APIClient) waitIfNeeded(bucket string) error {
|
||||||
c.mu.RLock()
|
api.mu.RLock()
|
||||||
state, exists := c.buckets[bucket]
|
state, exists := api.buckets[bucket]
|
||||||
c.mu.RUnlock()
|
api.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil // No state yet, proceed
|
return nil // No state yet, proceed
|
||||||
@@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
|
|||||||
waitDuration += 100 * time.Millisecond
|
waitDuration += 100 * time.Millisecond
|
||||||
|
|
||||||
if waitDuration > 0 {
|
if waitDuration > 0 {
|
||||||
c.logger.Debug().
|
api.logger.Debug().
|
||||||
Str("bucket", bucket).
|
Str("bucket", bucket).
|
||||||
Dur("wait_duration", waitDuration).
|
Dur("wait_duration", waitDuration).
|
||||||
Msg("Proactively waiting for rate limit reset")
|
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
|
// 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")
|
bucket := headers.Get("X-RateLimit-Bucket")
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
return // No bucket info, can't track
|
return // No bucket info, can't track
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse headers
|
// Parse headers
|
||||||
limit := c.parseInt(headers.Get("X-RateLimit-Limit"))
|
limit := api.parseInt(headers.Get("X-RateLimit-Limit"))
|
||||||
remaining := c.parseInt(headers.Get("X-RateLimit-Remaining"))
|
remaining := api.parseInt(headers.Get("X-RateLimit-Remaining"))
|
||||||
resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After"))
|
resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After"))
|
||||||
|
|
||||||
state := &RateLimitState{
|
state := &RateLimitState{
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
|
|||||||
Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))),
|
Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
api.mu.Lock()
|
||||||
c.buckets[bucket] = state
|
api.buckets[bucket] = state
|
||||||
c.mu.Unlock()
|
api.mu.Unlock()
|
||||||
|
|
||||||
// Log rate limit state for debugging
|
// Log rate limit state for debugging
|
||||||
c.logger.Debug().
|
api.logger.Debug().
|
||||||
Str("bucket", bucket).
|
Str("bucket", bucket).
|
||||||
Int("remaining", remaining).
|
Int("remaining", remaining).
|
||||||
Int("limit", limit).
|
Int("limit", limit).
|
||||||
@@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseRetryAfter extracts retry delay from Retry-After 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")
|
retryAfter := headers.Get("Retry-After")
|
||||||
if retryAfter == "" {
|
if retryAfter == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord returns seconds as float
|
// Discord returns seconds as float
|
||||||
seconds := c.parseFloat(retryAfter)
|
seconds := api.parseFloat(retryAfter)
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
return 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
|
// 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 == "" {
|
if s == "" {
|
||||||
return 0
|
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
|
// 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 == "" {
|
if s == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
20
internal/discord/steamid.go
Normal file
20
internal/discord/steamid.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
|
--breakpoint-md: 48rem;
|
||||||
--breakpoint-lg: 64rem;
|
--breakpoint-lg: 64rem;
|
||||||
--breakpoint-xl: 80rem;
|
--breakpoint-xl: 80rem;
|
||||||
--breakpoint-2xl: 96rem;
|
--breakpoint-2xl: 96rem;
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
--container-lg: 32rem;
|
--container-lg: 32rem;
|
||||||
--container-2xl: 42rem;
|
--container-2xl: 42rem;
|
||||||
--container-3xl: 48rem;
|
--container-3xl: 48rem;
|
||||||
|
--container-4xl: 56rem;
|
||||||
--container-5xl: 64rem;
|
--container-5xl: 64rem;
|
||||||
--container-7xl: 80rem;
|
--container-7xl: 80rem;
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
--text-6xl--line-height: 1;
|
--text-6xl--line-height: 1;
|
||||||
--text-9xl: 8rem;
|
--text-9xl: 8rem;
|
||||||
--text-9xl--line-height: 1;
|
--text-9xl--line-height: 1;
|
||||||
|
--font-weight-light: 300;
|
||||||
--font-weight-normal: 400;
|
--font-weight-normal: 400;
|
||||||
--font-weight-medium: 500;
|
--font-weight-medium: 500;
|
||||||
--font-weight-semibold: 600;
|
--font-weight-semibold: 600;
|
||||||
@@ -213,6 +216,9 @@
|
|||||||
.collapse {
|
.collapse {
|
||||||
visibility: collapse;
|
visibility: collapse;
|
||||||
}
|
}
|
||||||
|
.invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
.visible {
|
.visible {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
@@ -260,6 +266,9 @@
|
|||||||
.top-20 {
|
.top-20 {
|
||||||
top: calc(var(--spacing) * 20);
|
top: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
|
.top-full {
|
||||||
|
top: 100%;
|
||||||
|
}
|
||||||
.right-0 {
|
.right-0 {
|
||||||
right: calc(var(--spacing) * 0);
|
right: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
@@ -284,6 +293,12 @@
|
|||||||
.z-50 {
|
.z-50 {
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
.col-span-1 {
|
||||||
|
grid-column: span 1 / span 1;
|
||||||
|
}
|
||||||
|
.col-span-2 {
|
||||||
|
grid-column: span 2 / span 2;
|
||||||
|
}
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
@@ -308,6 +323,9 @@
|
|||||||
.-mt-2 {
|
.-mt-2 {
|
||||||
margin-top: calc(var(--spacing) * -2);
|
margin-top: calc(var(--spacing) * -2);
|
||||||
}
|
}
|
||||||
|
.-mt-3 {
|
||||||
|
margin-top: calc(var(--spacing) * -3);
|
||||||
|
}
|
||||||
.mt-0\.5 {
|
.mt-0\.5 {
|
||||||
margin-top: calc(var(--spacing) * 0.5);
|
margin-top: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -441,6 +459,9 @@
|
|||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
|
.h-\[calc\(100\%-3rem\)\] {
|
||||||
|
height: calc(100% - 3rem);
|
||||||
|
}
|
||||||
.h-full {
|
.h-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -450,6 +471,9 @@
|
|||||||
.max-h-60 {
|
.max-h-60 {
|
||||||
max-height: calc(var(--spacing) * 60);
|
max-height: calc(var(--spacing) * 60);
|
||||||
}
|
}
|
||||||
|
.max-h-80 {
|
||||||
|
max-height: calc(var(--spacing) * 80);
|
||||||
|
}
|
||||||
.max-h-96 {
|
.max-h-96 {
|
||||||
max-height: calc(var(--spacing) * 96);
|
max-height: calc(var(--spacing) * 96);
|
||||||
}
|
}
|
||||||
@@ -459,6 +483,12 @@
|
|||||||
.max-h-\[600px\] {
|
.max-h-\[600px\] {
|
||||||
max-height: 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-h-48 {
|
||||||
min-height: calc(var(--spacing) * 48);
|
min-height: calc(var(--spacing) * 48);
|
||||||
}
|
}
|
||||||
@@ -483,21 +513,27 @@
|
|||||||
.w-6 {
|
.w-6 {
|
||||||
width: calc(var(--spacing) * 6);
|
width: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.w-10 {
|
||||||
|
width: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
.w-12 {
|
.w-12 {
|
||||||
width: calc(var(--spacing) * 12);
|
width: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
.w-20 {
|
.w-20 {
|
||||||
width: calc(var(--spacing) * 20);
|
width: calc(var(--spacing) * 20);
|
||||||
}
|
}
|
||||||
.w-24 {
|
|
||||||
width: calc(var(--spacing) * 24);
|
|
||||||
}
|
|
||||||
.w-26 {
|
.w-26 {
|
||||||
width: calc(var(--spacing) * 26);
|
width: calc(var(--spacing) * 26);
|
||||||
}
|
}
|
||||||
.w-48 {
|
.w-48 {
|
||||||
width: calc(var(--spacing) * 48);
|
width: calc(var(--spacing) * 48);
|
||||||
}
|
}
|
||||||
|
.w-56 {
|
||||||
|
width: calc(var(--spacing) * 56);
|
||||||
|
}
|
||||||
|
.w-72 {
|
||||||
|
width: calc(var(--spacing) * 72);
|
||||||
|
}
|
||||||
.w-80 {
|
.w-80 {
|
||||||
width: calc(var(--spacing) * 80);
|
width: calc(var(--spacing) * 80);
|
||||||
}
|
}
|
||||||
@@ -513,6 +549,9 @@
|
|||||||
.max-w-3xl {
|
.max-w-3xl {
|
||||||
max-width: var(--container-3xl);
|
max-width: var(--container-3xl);
|
||||||
}
|
}
|
||||||
|
.max-w-4xl {
|
||||||
|
max-width: var(--container-4xl);
|
||||||
|
}
|
||||||
.max-w-5xl {
|
.max-w-5xl {
|
||||||
max-width: var(--container-5xl);
|
max-width: var(--container-5xl);
|
||||||
}
|
}
|
||||||
@@ -528,6 +567,9 @@
|
|||||||
.max-w-100 {
|
.max-w-100 {
|
||||||
max-width: calc(var(--spacing) * 100);
|
max-width: calc(var(--spacing) * 100);
|
||||||
}
|
}
|
||||||
|
.max-w-lg {
|
||||||
|
max-width: var(--container-lg);
|
||||||
|
}
|
||||||
.max-w-md {
|
.max-w-md {
|
||||||
max-width: var(--container-md);
|
max-width: var(--container-md);
|
||||||
}
|
}
|
||||||
@@ -537,6 +579,9 @@
|
|||||||
.max-w-screen-lg {
|
.max-w-screen-lg {
|
||||||
max-width: var(--breakpoint-lg);
|
max-width: var(--breakpoint-lg);
|
||||||
}
|
}
|
||||||
|
.max-w-screen-md {
|
||||||
|
max-width: var(--breakpoint-md);
|
||||||
|
}
|
||||||
.max-w-screen-xl {
|
.max-w-screen-xl {
|
||||||
max-width: var(--breakpoint-xl);
|
max-width: var(--breakpoint-xl);
|
||||||
}
|
}
|
||||||
@@ -588,6 +633,9 @@
|
|||||||
.cursor-grab {
|
.cursor-grab {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
.cursor-help {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
.cursor-not-allowed {
|
.cursor-not-allowed {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -600,12 +648,24 @@
|
|||||||
.resize-none {
|
.resize-none {
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
.list-inside {
|
||||||
|
list-style-position: inside;
|
||||||
|
}
|
||||||
|
.list-disc {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
.appearance-none {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.grid-cols-2 {
|
.grid-cols-2 {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.grid-cols-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.grid-cols-7 {
|
.grid-cols-7 {
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -621,6 +681,9 @@
|
|||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.items-end {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
.items-start {
|
.items-start {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@@ -636,6 +699,9 @@
|
|||||||
.gap-1 {
|
.gap-1 {
|
||||||
gap: calc(var(--spacing) * 1);
|
gap: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.gap-1\.5 {
|
||||||
|
gap: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
.gap-2 {
|
.gap-2 {
|
||||||
gap: calc(var(--spacing) * 2);
|
gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -651,6 +717,13 @@
|
|||||||
.gap-8 {
|
.gap-8 {
|
||||||
gap: calc(var(--spacing) * 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 {
|
.space-y-1 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -722,6 +795,14 @@
|
|||||||
.gap-y-5 {
|
.gap-y-5 {
|
||||||
row-gap: calc(var(--spacing) * 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 {
|
.divide-y {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-divide-y-reverse: 0;
|
--tw-divide-y-reverse: 0;
|
||||||
@@ -801,6 +882,12 @@
|
|||||||
.border-blue {
|
.border-blue {
|
||||||
border-color: var(--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-blue\/50 {
|
||||||
border-color: var(--blue);
|
border-color: var(--blue);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -822,6 +909,9 @@
|
|||||||
border-color: color-mix(in oklab, var(--red) 30%, transparent);
|
border-color: color-mix(in oklab, var(--red) 30%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.border-surface0 {
|
||||||
|
border-color: var(--surface0);
|
||||||
|
}
|
||||||
.border-surface1 {
|
.border-surface1 {
|
||||||
border-color: var(--surface1);
|
border-color: var(--surface1);
|
||||||
}
|
}
|
||||||
@@ -858,6 +948,12 @@
|
|||||||
.bg-blue {
|
.bg-blue {
|
||||||
background-color: var(--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 {
|
.bg-blue\/20 {
|
||||||
background-color: var(--blue);
|
background-color: var(--blue);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -891,6 +987,12 @@
|
|||||||
background-color: color-mix(in oklab, var(--green) 20%, transparent);
|
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 {
|
.bg-mantle {
|
||||||
background-color: var(--mantle);
|
background-color: var(--mantle);
|
||||||
}
|
}
|
||||||
@@ -900,6 +1002,12 @@
|
|||||||
.bg-peach {
|
.bg-peach {
|
||||||
background-color: var(--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 {
|
.bg-red {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
}
|
}
|
||||||
@@ -960,6 +1068,9 @@
|
|||||||
.p-2\.5 {
|
.p-2\.5 {
|
||||||
padding: calc(var(--spacing) * 2.5);
|
padding: calc(var(--spacing) * 2.5);
|
||||||
}
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -969,9 +1080,15 @@
|
|||||||
.p-8 {
|
.p-8 {
|
||||||
padding: calc(var(--spacing) * 8);
|
padding: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.px-1\.5 {
|
||||||
|
padding-inline: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
.px-2 {
|
.px-2 {
|
||||||
padding-inline: calc(var(--spacing) * 2);
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.px-2\.5 {
|
||||||
|
padding-inline: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.px-3 {
|
.px-3 {
|
||||||
padding-inline: calc(var(--spacing) * 3);
|
padding-inline: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@@ -990,6 +1107,9 @@
|
|||||||
.py-1 {
|
.py-1 {
|
||||||
padding-block: calc(var(--spacing) * 1);
|
padding-block: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.py-1\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
.py-2 {
|
.py-2 {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -1008,6 +1128,9 @@
|
|||||||
.pt-2 {
|
.pt-2 {
|
||||||
padding-top: calc(var(--spacing) * 2);
|
padding-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.pt-3 {
|
||||||
|
padding-top: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.pt-4 {
|
.pt-4 {
|
||||||
padding-top: calc(var(--spacing) * 4);
|
padding-top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -1083,6 +1206,10 @@
|
|||||||
--tw-leading: calc(var(--spacing) * 6);
|
--tw-leading: calc(var(--spacing) * 6);
|
||||||
line-height: calc(var(--spacing) * 6);
|
line-height: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.leading-none {
|
||||||
|
--tw-leading: 1;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.leading-relaxed {
|
.leading-relaxed {
|
||||||
--tw-leading: var(--leading-relaxed);
|
--tw-leading: var(--leading-relaxed);
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
@@ -1091,6 +1218,10 @@
|
|||||||
--tw-font-weight: var(--font-weight-bold);
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
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 {
|
.font-medium {
|
||||||
--tw-font-weight: var(--font-weight-medium);
|
--tw-font-weight: var(--font-weight-medium);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
@@ -1141,21 +1272,51 @@
|
|||||||
.text-overlay0 {
|
.text-overlay0 {
|
||||||
color: var(--overlay0);
|
color: var(--overlay0);
|
||||||
}
|
}
|
||||||
|
.text-peach {
|
||||||
|
color: var(--peach);
|
||||||
|
}
|
||||||
.text-red {
|
.text-red {
|
||||||
color: var(--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 {
|
.text-subtext0 {
|
||||||
color: var(--subtext0);
|
color: var(--subtext0);
|
||||||
}
|
}
|
||||||
.text-subtext1 {
|
.text-subtext1 {
|
||||||
color: var(--subtext1);
|
color: var(--subtext1);
|
||||||
}
|
}
|
||||||
|
.text-teal {
|
||||||
|
color: var(--teal);
|
||||||
|
}
|
||||||
.text-text {
|
.text-text {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.text-yellow {
|
.text-yellow {
|
||||||
color: var(--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 {
|
.text-yellow\/80 {
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1174,6 +1335,11 @@
|
|||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.placeholder-subtext0 {
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--subtext0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
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));
|
--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);
|
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 {
|
.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));
|
--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);
|
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;
|
-webkit-user-select: none;
|
||||||
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\:cursor-pointer {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:bg-red\/25 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:bg-red\/75 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-blue {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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-blue {
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: var(--blue);
|
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 {
|
&:disabled {
|
||||||
opacity: 50%;
|
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 {
|
.sm\:end-6 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
inset-inline-end: calc(var(--spacing) * 6);
|
inset-inline-end: calc(var(--spacing) * 6);
|
||||||
@@ -1668,6 +1998,11 @@
|
|||||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
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 {
|
.sm\:flex-row {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -1789,11 +2124,21 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.lg\:grid-cols-2 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.lg\:grid-cols-3 {
|
.lg\:grid-cols-3 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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 {
|
.lg\:items-end {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -2029,7 +2374,7 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0;
|
initial-value: 0;
|
||||||
}
|
}
|
||||||
@property --tw-divide-y-reverse {
|
@property --tw-divide-x-reverse {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0;
|
initial-value: 0;
|
||||||
@@ -2039,6 +2384,11 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: solid;
|
initial-value: solid;
|
||||||
}
|
}
|
||||||
|
@property --tw-divide-y-reverse {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
@property --tw-leading {
|
@property --tw-leading {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -2198,8 +2548,9 @@
|
|||||||
--tw-skew-y: initial;
|
--tw-skew-y: initial;
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
--tw-space-x-reverse: 0;
|
--tw-space-x-reverse: 0;
|
||||||
--tw-divide-y-reverse: 0;
|
--tw-divide-x-reverse: 0;
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
|
--tw-divide-y-reverse: 0;
|
||||||
--tw-leading: initial;
|
--tw-leading: initial;
|
||||||
--tw-font-weight: initial;
|
--tw-font-weight: initial;
|
||||||
--tw-tracking: initial;
|
--tw-tracking: initial;
|
||||||
|
|||||||
84
internal/embedfs/web/js/localtime.js
Normal file
84
internal/embedfs/web/js/localtime.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// localtime.js - Converts UTC <time> elements to the user's local timezone.
|
||||||
|
//
|
||||||
|
// Usage: <time datetime="2026-01-14T10:30:00Z" data-localtime="datetime">fallback</time>
|
||||||
|
//
|
||||||
|
// Supported data-localtime values:
|
||||||
|
// "date" → "Mon 2 Jan 2026"
|
||||||
|
// "time" → "3:04 PM"
|
||||||
|
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
|
||||||
|
// "short" → "Mon 2 Jan 3:04 PM"
|
||||||
|
// "histdate" → "2 Jan 2006 15:04"
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
|
||||||
|
function formatTime12(d) {
|
||||||
|
let h = d.getHours();
|
||||||
|
const m = pad(d.getMinutes());
|
||||||
|
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||||
|
h = h % 12 || 12;
|
||||||
|
return h + ':' + m + ' ' + ampm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
|
||||||
|
SHORT_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalTime(el) {
|
||||||
|
const iso = el.getAttribute('datetime');
|
||||||
|
if (!iso) return;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return;
|
||||||
|
|
||||||
|
const fmt = el.getAttribute('data-localtime');
|
||||||
|
let text;
|
||||||
|
switch (fmt) {
|
||||||
|
case 'date':
|
||||||
|
text = formatDate(d);
|
||||||
|
break;
|
||||||
|
case 'time':
|
||||||
|
text = formatTime12(d);
|
||||||
|
break;
|
||||||
|
case 'datetime':
|
||||||
|
text = formatDate(d) + ' at ' + formatTime12(d);
|
||||||
|
break;
|
||||||
|
case 'short':
|
||||||
|
text = SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
|
||||||
|
SHORT_MONTHS[d.getMonth()] + ' ' + formatTime12(d);
|
||||||
|
break;
|
||||||
|
case 'histdate':
|
||||||
|
text = d.getDate() + ' ' + SHORT_MONTHS[d.getMonth()] + ' ' +
|
||||||
|
d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
text = formatDate(d) + ' at ' + formatTime12(d);
|
||||||
|
}
|
||||||
|
el.textContent = text;
|
||||||
|
|
||||||
|
// Add timezone tooltip so users know the displayed time is in their local timezone
|
||||||
|
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
el.title = 'Displayed in your local timezone (' + tz + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function processAll(root) {
|
||||||
|
const els = (root || document).querySelectorAll('time[data-localtime]');
|
||||||
|
els.forEach(formatLocalTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { processAll(); });
|
||||||
|
} else {
|
||||||
|
processAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-process after HTMX swaps
|
||||||
|
document.addEventListener('htmx:afterSettle', function (evt) {
|
||||||
|
processAll(evt.detail.elt);
|
||||||
|
});
|
||||||
|
})();
|
||||||
2
internal/embedfs/web/vendored/sortablejs@1.15.6.min.js
vendored
Normal file
2
internal/embedfs/web/vendored/sortablejs@1.15.6.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -84,7 +84,7 @@ func AdminRoleCreate(s *hws.Server, conn *db.DB) http.Handler {
|
|||||||
CreatedAt: time.Now().Unix(),
|
CreatedAt: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := db.CreateRole(ctx, tx, newRole, db.NewAudit(r, nil))
|
err := db.CreateRole(ctx, tx, newRole, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.CreateRole")
|
return false, errors.Wrap(err, "db.CreateRole")
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func AdminRoleDelete(s *hws.Server, conn *db.DB) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete the role with audit logging
|
// Delete the role with audit logging
|
||||||
err = db.DeleteRole(ctx, tx, roleID, db.NewAudit(r, nil))
|
err = db.DeleteRole(ctx, tx, roleID, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.NotFound(w, err)
|
respond.NotFound(w, err)
|
||||||
@@ -320,7 +320,7 @@ func AdminRolePermissionsUpdate(s *hws.Server, conn *db.DB) http.Handler {
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetRoleByID")
|
return false, errors.Wrap(err, "db.GetRoleByID")
|
||||||
}
|
}
|
||||||
err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAudit(r, nil))
|
err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "role.UpdatePermissions")
|
return false, errors.Wrap(err, "role.UpdatePermissions")
|
||||||
}
|
}
|
||||||
|
|||||||
501
internal/handlers/fixture_detail.go
Normal file
501
internal/handlers/fixture_detail.go
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"git.haelnorr.com/h/timefmt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
|
||||||
|
// result display, and team rosters
|
||||||
|
func FixtureDetailPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTab := r.URL.Query().Get("tab")
|
||||||
|
if activeTab == "" {
|
||||||
|
activeTab = "overview"
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture *db.Fixture
|
||||||
|
var currentSchedule *db.FixtureSchedule
|
||||||
|
var history []*db.FixtureSchedule
|
||||||
|
var canSchedule bool
|
||||||
|
var userTeamID int
|
||||||
|
var result *db.FixtureResult
|
||||||
|
var rosters map[string][]*db.PlayerWithPlayStatus
|
||||||
|
var nominatedFreeAgents []*db.FixtureFreeAgent
|
||||||
|
var availableFreeAgents []*db.SeasonLeagueFreeAgent
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule")
|
||||||
|
}
|
||||||
|
history, err = db.GetFixtureScheduleHistory(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixtureScheduleHistory")
|
||||||
|
}
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
// Fetch fixture result if it exists
|
||||||
|
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||||
|
}
|
||||||
|
// Fetch team rosters with play status
|
||||||
|
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
|
||||||
|
}
|
||||||
|
// Fetch free agent nominations for this fixture
|
||||||
|
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
|
||||||
|
}
|
||||||
|
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
|
||||||
|
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
|
||||||
|
if canSchedule || canManage {
|
||||||
|
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.FixtureDetailPage(
|
||||||
|
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||||
|
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
||||||
|
), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProposeSchedule handles POST /fixtures/{fixture_id}/schedule
|
||||||
|
func ProposeSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||||
|
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||||
|
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||||
|
// scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||||
|
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ProposeFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.ProposeFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/accept
|
||||||
|
func AcceptSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.AcceptFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.AcceptFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Schedule Accepted", "The fixture time has been confirmed.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/reject
|
||||||
|
func RejectSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, _, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.RejectFixtureSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.RejectFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostponeSchedule handles POST /fixtures/{fixture_id}/schedule/postpone
|
||||||
|
func PostponeSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, _, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to postpone a fixture", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.PostponeFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.PostponeFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Fixture Postponed", "The fixture has been postponed.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RescheduleFixture handles POST /fixtures/{fixture_id}/schedule/reschedule
|
||||||
|
func RescheduleFixture(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||||
|
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||||
|
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||||
|
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||||
|
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to reschedule a fixture", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.RescheduleFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.RescheduleFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Fixture Rescheduled", "The new proposed time has been submitted.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/withdraw
|
||||||
|
// Only the proposing team manager can withdraw their own pending proposal.
|
||||||
|
func WithdrawSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.WithdrawFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.WithdrawFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelSchedule handles POST /fixtures/{fixture_id}/schedule/cancel
|
||||||
|
// This is a moderator-only action that requires fixtures.manage permission.
|
||||||
|
func CancelSchedule(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.CancelFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.CancelFixtureSchedule")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Forfeit Declared", "The fixture has been declared a forfeit.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
471
internal/handlers/fixture_result.go
Normal file
471
internal/handlers/fixture_result.go
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxUploadSize = 10 << 20 // 10 MB
|
||||||
|
|
||||||
|
// UploadMatchLogsPage renders the upload form for match log files
|
||||||
|
func UploadMatchLogsPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture *db.Fixture
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
// Check if result already exists
|
||||||
|
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
throw.BadRequest(s, w, r, "A result already exists for this fixture. Discard it first to re-upload.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.FixtureUploadResultPage(fixture), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadMatchLogs handles POST /fixtures/{fixture_id}/results/upload
|
||||||
|
// Parses 3 multipart files, validates, detects tampering, and stores results.
|
||||||
|
func UploadMatchLogs(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form
|
||||||
|
err = r.ParseMultipartForm(maxUploadSize)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files. Ensure files are under 10MB.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the 3 period files
|
||||||
|
periodNames := []string{"period_1", "period_2", "period_3"}
|
||||||
|
logs := make([]*slapshotapi.MatchLog, 3)
|
||||||
|
for i, name := range periodNames {
|
||||||
|
file, _, err := r.FormFile(name)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Missing File", "All 3 period files are required. Missing: "+name, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Read Error", "Could not read file: "+name, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log, err := slapshotapi.ParseMatchLog(data)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Parse Error", "Could not parse "+name+": "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logs[i] = log
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect tampering
|
||||||
|
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Validation Error", "Tampering check failed: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *db.FixtureResult
|
||||||
|
var unmappedPlayers []string
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if result already exists
|
||||||
|
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
notify.Warn(s, w, r, "Result Exists", "A result already exists for this fixture. Discard it first to re-upload.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique game_user_ids across all periods
|
||||||
|
gameUserIDSet := map[string]bool{}
|
||||||
|
for _, log := range logs {
|
||||||
|
for _, p := range log.Players {
|
||||||
|
gameUserIDSet[p.GameUserID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameUserIDs := make([]string, 0, len(gameUserIDSet))
|
||||||
|
for id := range gameUserIDSet {
|
||||||
|
gameUserIDs = append(gameUserIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map game_user_ids to players
|
||||||
|
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine team orientation using all players from all periods
|
||||||
|
allPlayers := []slapshotapi.Player{}
|
||||||
|
// Use period 3 players for orientation (most complete)
|
||||||
|
allPlayers = append(allPlayers, logs[2].Players...)
|
||||||
|
|
||||||
|
fixtureHomeIsLogsHome, unmapped, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
|
||||||
|
if err != nil {
|
||||||
|
notify.Warn(s, w, r, "Orientation Error",
|
||||||
|
"Could not determine team orientation: "+err.Error()+". Please ensure players have registered Slapshot IDs.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
unmappedPlayers = unmapped
|
||||||
|
|
||||||
|
// Use period 3 (final) data for the result
|
||||||
|
finalLog := logs[2]
|
||||||
|
|
||||||
|
// Determine winner in fixture terms
|
||||||
|
winner := finalLog.Winner
|
||||||
|
homeScore := finalLog.Score.Home
|
||||||
|
awayScore := finalLog.Score.Away
|
||||||
|
if !fixtureHomeIsLogsHome {
|
||||||
|
// Logs are reversed - swap
|
||||||
|
switch winner {
|
||||||
|
case "home":
|
||||||
|
winner = "away"
|
||||||
|
case "away":
|
||||||
|
winner = "home"
|
||||||
|
}
|
||||||
|
homeScore, awayScore = awayScore, homeScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse metadata
|
||||||
|
periodsEnabled := finalLog.PeriodsEnabled == "True"
|
||||||
|
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
|
||||||
|
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
|
||||||
|
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
|
||||||
|
// Build result
|
||||||
|
var tamperingReasonPtr *string
|
||||||
|
if tamperingDetected {
|
||||||
|
tamperingReasonPtr = &tamperingReason
|
||||||
|
}
|
||||||
|
|
||||||
|
result = &db.FixtureResult{
|
||||||
|
FixtureID: fixtureID,
|
||||||
|
Winner: winner,
|
||||||
|
HomeScore: homeScore,
|
||||||
|
AwayScore: awayScore,
|
||||||
|
MatchType: finalLog.Type,
|
||||||
|
Arena: finalLog.Arena,
|
||||||
|
EndReason: finalLog.EndReason,
|
||||||
|
PeriodsEnabled: periodsEnabled,
|
||||||
|
CustomMercyRule: customMercyRule,
|
||||||
|
MatchLength: matchLength,
|
||||||
|
UploadedByUserID: user.ID,
|
||||||
|
Finalized: false,
|
||||||
|
TamperingDetected: tamperingDetected,
|
||||||
|
TamperingReason: tamperingReasonPtr,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build player stats for all 3 periods
|
||||||
|
playerStats := []*db.FixtureResultPlayerStats{}
|
||||||
|
for periodIdx, log := range logs {
|
||||||
|
periodNum := periodIdx + 1
|
||||||
|
for _, p := range log.Players {
|
||||||
|
// Determine team in fixture terms
|
||||||
|
team := p.Team
|
||||||
|
if !fixtureHomeIsLogsHome {
|
||||||
|
if team == "home" {
|
||||||
|
team = "away"
|
||||||
|
} else {
|
||||||
|
team = "home"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up player
|
||||||
|
var playerID *int
|
||||||
|
var teamID *int
|
||||||
|
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
|
||||||
|
playerID = &lookup.Player.ID
|
||||||
|
if !lookup.Unmapped {
|
||||||
|
teamID = &lookup.TeamID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stat := &db.FixtureResultPlayerStats{
|
||||||
|
PeriodNum: periodNum,
|
||||||
|
PlayerID: playerID,
|
||||||
|
PlayerGameUserID: p.GameUserID,
|
||||||
|
PlayerUsername: p.Username,
|
||||||
|
TeamID: teamID,
|
||||||
|
Team: team,
|
||||||
|
// Convert float stats to int
|
||||||
|
Goals: FloatToIntPtr(p.Stats.Goals),
|
||||||
|
Assists: FloatToIntPtr(p.Stats.Assists),
|
||||||
|
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
|
||||||
|
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
|
||||||
|
Saves: FloatToIntPtr(p.Stats.Saves),
|
||||||
|
Blocks: FloatToIntPtr(p.Stats.Blocks),
|
||||||
|
Shots: FloatToIntPtr(p.Stats.Shots),
|
||||||
|
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
|
||||||
|
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
|
||||||
|
Passes: FloatToIntPtr(p.Stats.Passes),
|
||||||
|
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
|
||||||
|
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
|
||||||
|
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
|
||||||
|
PostHits: FloatToIntPtr(p.Stats.PostHits),
|
||||||
|
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
|
||||||
|
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
|
||||||
|
Score: FloatToIntPtr(p.Stats.Score),
|
||||||
|
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
|
||||||
|
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
|
||||||
|
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
|
||||||
|
Wins: FloatToIntPtr(p.Stats.Wins),
|
||||||
|
Losses: FloatToIntPtr(p.Stats.Losses),
|
||||||
|
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
|
||||||
|
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
|
||||||
|
Ties: FloatToIntPtr(p.Stats.Ties),
|
||||||
|
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
|
||||||
|
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
|
||||||
|
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
|
||||||
|
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
|
||||||
|
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
|
||||||
|
}
|
||||||
|
playerStats = append(playerStats, stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each player stat: if the player is a registered free agent, mark them
|
||||||
|
for _, ps := range playerStats {
|
||||||
|
if ps.PlayerID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if the player is a registered free agent
|
||||||
|
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if isFA {
|
||||||
|
ps.IsFreeAgent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert result and stats
|
||||||
|
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.InsertFixtureResult")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = unmappedPlayers // stored for review page redirect
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewMatchResult handles GET /fixtures/{fixture_id}/results/review
|
||||||
|
func ReviewMatchResult(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture *db.Fixture
|
||||||
|
var result *db.FixtureResult
|
||||||
|
var unmappedPlayers []string
|
||||||
|
var faWarnings []seasonsview.FreeAgentWarning
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = db.GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get nominated free agents for this fixture
|
||||||
|
nominatedFAs, err := db.GetNominatedFreeAgents(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
|
||||||
|
}
|
||||||
|
// Map player ID to the side ("home"/"away") that nominated them
|
||||||
|
nominatedFASide := map[int]string{}
|
||||||
|
for _, nfa := range nominatedFAs {
|
||||||
|
if nfa.TeamID == fixture.HomeTeamID {
|
||||||
|
nominatedFASide[nfa.PlayerID] = "home"
|
||||||
|
} else {
|
||||||
|
nominatedFASide[nfa.PlayerID] = "away"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to resolve side to team name
|
||||||
|
teamNameForSide := func(side string) string {
|
||||||
|
if side == "home" {
|
||||||
|
return fixture.HomeTeam.Name
|
||||||
|
}
|
||||||
|
return fixture.AwayTeam.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build unmapped players and free agent warnings from stats
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for _, ps := range result.PlayerStats {
|
||||||
|
if ps.PeriodNum != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ps.PlayerID == nil {
|
||||||
|
unmappedPlayers = append(unmappedPlayers,
|
||||||
|
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
||||||
|
} else if ps.IsFreeAgent && !seen[*ps.PlayerID] {
|
||||||
|
seen[*ps.PlayerID] = true
|
||||||
|
nominatedSide, wasNominated := nominatedFASide[*ps.PlayerID]
|
||||||
|
if !wasNominated {
|
||||||
|
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
|
||||||
|
Name: ps.PlayerUsername,
|
||||||
|
Reason: "not nominated for this fixture",
|
||||||
|
})
|
||||||
|
} else if nominatedSide != ps.Team {
|
||||||
|
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
|
||||||
|
Name: ps.PlayerUsername,
|
||||||
|
Reason: "nominated by " + teamNameForSide(nominatedSide) + ", but played for " + teamNameForSide(ps.Team),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinalizeMatchResult handles POST /fixtures/{fixture_id}/results/finalize
|
||||||
|
func FinalizeMatchResult(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.FinalizeFixtureResult(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.FinalizeFixtureResult")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Result Finalized", "The match result has been finalized.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscardMatchResult handles POST /fixtures/{fixture_id}/results/discard
|
||||||
|
func DiscardMatchResult(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.DeleteFixtureResult(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.DeleteFixtureResult")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Result Discarded", "The match result has been discarded. You can upload new logs.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
187
internal/handlers/fixture_result_validation.go
Normal file
187
internal/handlers/fixture_result_validation.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayerLookupResult stores the resolved player info from a game_user_id lookup
|
||||||
|
type PlayerLookupResult struct {
|
||||||
|
Player *db.Player
|
||||||
|
TeamID int
|
||||||
|
Found bool
|
||||||
|
Unmapped bool // true if player not in system (potential free agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapGameUserIDsToPlayers creates a lookup map from game_user_id to resolved player info.
|
||||||
|
// It looks up players by their SlapID (which corresponds to game_user_id in match logs)
|
||||||
|
// and checks their team assignment in the given season/league.
|
||||||
|
func MapGameUserIDsToPlayers(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
gameUserIDs []string,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) (map[string]*PlayerLookupResult, error) {
|
||||||
|
result := make(map[string]*PlayerLookupResult, len(gameUserIDs))
|
||||||
|
|
||||||
|
// Initialize all as unmapped
|
||||||
|
for _, id := range gameUserIDs {
|
||||||
|
result[id] = &PlayerLookupResult{Unmapped: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gameUserIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all players that have a slap_id matching any of the game_user_ids
|
||||||
|
// game_user_id in logs is a string representation of the slapshot player ID (uint32)
|
||||||
|
players := []*db.Player{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&players).
|
||||||
|
Where("p.slap_id::text IN (?)", bun.In(gameUserIDs)).
|
||||||
|
Relation("User").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect players")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of slapID -> player
|
||||||
|
slapIDToPlayer := make(map[string]*db.Player, len(players))
|
||||||
|
playerIDs := make([]int, 0, len(players))
|
||||||
|
for _, p := range players {
|
||||||
|
if p.SlapID != nil {
|
||||||
|
key := slapIDStr(*p.SlapID)
|
||||||
|
slapIDToPlayer[key] = p
|
||||||
|
playerIDs = append(playerIDs, p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team roster entries for these players in the given season/league
|
||||||
|
rosters := []*db.TeamRoster{}
|
||||||
|
if len(playerIDs) > 0 {
|
||||||
|
err = tx.NewSelect().
|
||||||
|
Model(&rosters).
|
||||||
|
Where("tr.season_id = ?", seasonID).
|
||||||
|
Where("tr.league_id = ?", leagueID).
|
||||||
|
Where("tr.player_id IN (?)", bun.In(playerIDs)).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect rosters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build playerID -> teamID map
|
||||||
|
playerTeam := make(map[int]int, len(rosters))
|
||||||
|
for _, r := range rosters {
|
||||||
|
playerTeam[r.PlayerID] = r.TeamID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate results
|
||||||
|
for _, id := range gameUserIDs {
|
||||||
|
player, found := slapIDToPlayer[id]
|
||||||
|
if !found {
|
||||||
|
continue // stays unmapped
|
||||||
|
}
|
||||||
|
teamID, onTeam := playerTeam[player.ID]
|
||||||
|
result[id] = &PlayerLookupResult{
|
||||||
|
Player: player,
|
||||||
|
TeamID: teamID,
|
||||||
|
Found: true,
|
||||||
|
Unmapped: !onTeam,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetermineTeamOrientation validates that logs match fixture's team assignment
|
||||||
|
// by cross-checking player game_user_ids against registered rosters.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - fixtureHomeIsLogsHome: true if fixture's home team maps to "home" in logs
|
||||||
|
// - unmappedPlayers: list of game_user_ids that couldn't be resolved
|
||||||
|
// - error: if orientation cannot be determined
|
||||||
|
func DetermineTeamOrientation(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixture *db.Fixture,
|
||||||
|
allPlayers []slapshotapi.Player,
|
||||||
|
playerLookup map[string]*PlayerLookupResult,
|
||||||
|
) (bool, []string, error) {
|
||||||
|
if fixture == nil {
|
||||||
|
return false, nil, errors.New("fixture cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
unmapped := []string{}
|
||||||
|
|
||||||
|
// Count how many fixture-home-team players are on "home" vs "away" in logs
|
||||||
|
homeTeamOnHome := 0 // fixture home team players that are "home" in logs
|
||||||
|
homeTeamOnAway := 0 // fixture home team players that are "away" in logs
|
||||||
|
awayTeamOnHome := 0 // fixture away team players that are "home" in logs
|
||||||
|
awayTeamOnAway := 0 // fixture away team players that are "away" in logs
|
||||||
|
|
||||||
|
for _, p := range allPlayers {
|
||||||
|
lookup, exists := playerLookup[p.GameUserID]
|
||||||
|
if !exists || lookup.Unmapped {
|
||||||
|
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logTeam := p.Team // "home" or "away" in the log
|
||||||
|
|
||||||
|
switch lookup.TeamID {
|
||||||
|
case fixture.HomeTeamID:
|
||||||
|
if logTeam == "home" {
|
||||||
|
homeTeamOnHome++
|
||||||
|
} else {
|
||||||
|
homeTeamOnAway++
|
||||||
|
}
|
||||||
|
case fixture.AwayTeamID:
|
||||||
|
if logTeam == "home" {
|
||||||
|
awayTeamOnHome++
|
||||||
|
} else {
|
||||||
|
awayTeamOnAway++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Player is on a team but not one of the fixture teams
|
||||||
|
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMapped := homeTeamOnHome + homeTeamOnAway + awayTeamOnHome + awayTeamOnAway
|
||||||
|
if totalMapped == 0 {
|
||||||
|
return false, unmapped, errors.New("no mapped players found, cannot determine team orientation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate orientation: how many agree with "home=home" vs "home=away"
|
||||||
|
matchOrientation := homeTeamOnHome + awayTeamOnAway // logs match fixture orientation
|
||||||
|
reverseOrientation := homeTeamOnAway + awayTeamOnHome // logs are reversed
|
||||||
|
|
||||||
|
if matchOrientation == reverseOrientation {
|
||||||
|
return false, unmapped, errors.New("cannot determine team orientation: equal evidence for both orientations")
|
||||||
|
}
|
||||||
|
|
||||||
|
fixtureHomeIsLogsHome := matchOrientation > reverseOrientation
|
||||||
|
return fixtureHomeIsLogsHome, unmapped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatToIntPtr converts a *float64 to *int by truncating the decimal.
|
||||||
|
// Returns nil if input is nil.
|
||||||
|
func FloatToIntPtr(f *float64) *int {
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := int(math.Round(*f))
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// slapIDStr converts a uint32 SlapID to a string for map lookups
|
||||||
|
func slapIDStr(id uint32) string {
|
||||||
|
return fmt.Sprintf("%d", id)
|
||||||
|
}
|
||||||
@@ -31,11 +31,10 @@ func GenerateFixtures(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
_, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAudit(r, nil))
|
_, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
||||||
@@ -43,7 +42,7 @@ func GenerateFixtures(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.NewRound")
|
return false, errors.Wrap(err, "db.NewRound")
|
||||||
}
|
}
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixtures")
|
return false, errors.Wrap(err, "db.GetFixtures")
|
||||||
}
|
}
|
||||||
@@ -52,7 +51,7 @@ func GenerateFixtures(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +68,7 @@ func UpdateFixtures(
|
|||||||
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
|
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
|
||||||
allocations := getter.GetMaps("allocations")
|
allocations := getter.GetMaps("allocations")
|
||||||
if !getter.ValidateAndNotify(s, w, r) {
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates, err := mapUpdates(allocations)
|
updates, err := mapUpdates(allocations)
|
||||||
@@ -81,7 +81,7 @@ func UpdateFixtures(
|
|||||||
|
|
||||||
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
_, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
|
||||||
@@ -95,7 +95,7 @@ func UpdateFixtures(
|
|||||||
notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil)
|
notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAudit(r, nil))
|
err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
|
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
|
||||||
@@ -122,7 +122,7 @@ func DeleteFixture(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAudit(r, nil))
|
err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture"))
|
respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture"))
|
||||||
@@ -158,11 +158,28 @@ func updateFixtures(fixtures []*db.Fixture, updates map[int]int) ([]*db.Fixture,
|
|||||||
gameWeeks := map[int]int{}
|
gameWeeks := map[int]int{}
|
||||||
for _, fixture := range fixtures {
|
for _, fixture := range fixtures {
|
||||||
if gameWeek, exists := updates[fixture.ID]; exists {
|
if gameWeek, exists := updates[fixture.ID]; exists {
|
||||||
fixture.GameWeek = &gameWeek
|
var newValue *int
|
||||||
|
var oldValue int
|
||||||
|
if fixture.GameWeek != nil {
|
||||||
|
oldValue = *fixture.GameWeek
|
||||||
|
} else {
|
||||||
|
oldValue = 0
|
||||||
|
}
|
||||||
|
if gameWeek == 0 {
|
||||||
|
newValue = nil
|
||||||
|
} else {
|
||||||
|
newValue = &gameWeek
|
||||||
|
}
|
||||||
|
if gameWeek != oldValue {
|
||||||
|
fixture.GameWeek = newValue
|
||||||
updated = append(updated, fixture)
|
updated = append(updated, fixture)
|
||||||
}
|
}
|
||||||
|
// fuck i hate pointers sometimes
|
||||||
|
}
|
||||||
|
if fixture.GameWeek != nil {
|
||||||
gameWeeks[*fixture.GameWeek]++
|
gameWeeks[*fixture.GameWeek]++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for i := range len(gameWeeks) {
|
for i := range len(gameWeeks) {
|
||||||
count, exists := gameWeeks[i+1]
|
count, exists := gameWeeks[i+1]
|
||||||
if !exists || count < 1 {
|
if !exists || count < 1 {
|
||||||
|
|||||||
356
internal/handlers/free_agents.go
Normal file
356
internal/handlers/free_agents.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeAgentsListPage renders the free agents tab of a season league page
|
||||||
|
func FreeAgentsListPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
var season *db.Season
|
||||||
|
var league *db.League
|
||||||
|
var freeAgents []*db.SeasonLeagueFreeAgent
|
||||||
|
var availablePlayers []*db.Player
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
|
}
|
||||||
|
season = sl.Season
|
||||||
|
league = sl.League
|
||||||
|
|
||||||
|
freeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
availablePlayers, err = db.GetPlayersNotOnTeam(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out players already registered as free agents
|
||||||
|
faMap := make(map[int]bool, len(freeAgents))
|
||||||
|
for _, fa := range freeAgents {
|
||||||
|
faMap[fa.PlayerID] = true
|
||||||
|
}
|
||||||
|
filtered := make([]*db.Player, 0, len(availablePlayers))
|
||||||
|
for _, p := range availablePlayers {
|
||||||
|
if !faMap[p.ID] {
|
||||||
|
filtered = append(filtered, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availablePlayers = filtered
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(seasonsview.SeasonLeagueFreeAgentsPage(season, league, freeAgents, availablePlayers), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(seasonsview.SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFreeAgent handles POST to register a player as a free agent
|
||||||
|
func RegisterFreeAgent(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
respond.BadRequest(w, errors.New("failed to parse form"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := getter.Int("player_id").Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
respond.BadRequest(w, errors.New("invalid form data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify player is not on a team in this season_league
|
||||||
|
players, err := db.GetPlayersNotOnTeam(ctx, tx, sl.Season.ID, sl.League.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
playerFound := false
|
||||||
|
for _, p := range players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
playerFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !playerFound {
|
||||||
|
notify.Warn(s, w, r, "Cannot Register", "Player is already on a team in this league.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already registered
|
||||||
|
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, sl.Season.ID, sl.League.ID, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if isRegistered {
|
||||||
|
notify.Warn(s, w, r, "Already Registered", "Player is already registered as a free agent.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.RegisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.RegisterFreeAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Free Agent Registered", "Player has been registered as a free agent.", nil)
|
||||||
|
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterFreeAgent handles POST to unregister a player as a free agent
|
||||||
|
func UnregisterFreeAgent(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
respond.BadRequest(w, errors.New("failed to parse form"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := getter.Int("player_id").Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
respond.BadRequest(w, errors.New("invalid form data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.UnregisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.UnregisterFreeAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Free Agent Removed", "Player has been unregistered as a free agent.", nil)
|
||||||
|
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NominateFreeAgentHandler handles POST to nominate a free agent for a fixture
|
||||||
|
func NominateFreeAgentHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := getter.Int("player_id").Required().Value
|
||||||
|
teamID := getter.Int("team_id").Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
// Verify fixture exists and user is a manager
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can nominate: either a manager of the nominating team,
|
||||||
|
// or has fixtures.manage permission (can nominate for either team)
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
|
||||||
|
if !canManage {
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule || userTeamID != teamID {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a manager of the nominating team", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Verify the team_id is actually one of the fixture's teams
|
||||||
|
if teamID != fixture.HomeTeamID && teamID != fixture.AwayTeamID {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid team for this fixture", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify player is a registered free agent in this season_league
|
||||||
|
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if !isRegistered {
|
||||||
|
notify.Warn(s, w, r, "Not Registered", "Player is not a registered free agent in this league.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.NominateFreeAgent(ctx, tx, fixtureID, playerID, teamID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Nominate", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.NominateFreeAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Free Agent Nominated", "Free agent has been nominated for this fixture.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFreeAgentNominationHandler handles POST to remove a free agent nomination
|
||||||
|
func RemoveFreeAgentNominationHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(r.PathValue("player_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid player ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
// Check if user can remove: either has fixtures.manage permission,
|
||||||
|
// or is a manager of the team that nominated the free agent
|
||||||
|
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
|
||||||
|
if !canManage {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to remove nominations", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Verify the nomination belongs to the user's team
|
||||||
|
nominations, err := db.GetNominatedFreeAgentsByTeam(ctx, tx, fixtureID, userTeamID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgentsByTeam")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, n := range nominations {
|
||||||
|
if n.PlayerID == playerID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
throw.Forbidden(s, w, r, "You can only remove nominations made by your team", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.RemoveFreeAgentNomination(ctx, tx, fixtureID, playerID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.RemoveFreeAgentNomination")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Nomination Removed", "Free agent nomination has been removed.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func NewLeagueSubmit(
|
|||||||
if !nameUnique || !shortNameUnique {
|
if !nameUnique || !shortNameUnique {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAudit(r, nil))
|
league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.NewLeague")
|
return false, errors.Wrap(err, "db.NewLeague")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,20 @@ import (
|
|||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/internal/respond"
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
authview "git.haelnorr.com/h/oslstats/internal/view/authview"
|
authview "git.haelnorr.com/h/oslstats/internal/view/authview"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Register(
|
func Register(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
|
slapAPI *slapshotapi.SlapAPI,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
store *store.Store,
|
store *store.Store,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
@@ -56,6 +60,7 @@ func Register(
|
|||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
unique := false
|
unique := false
|
||||||
var user *db.User
|
var user *db.User
|
||||||
|
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
|
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,19 +69,13 @@ func Register(
|
|||||||
if !unique {
|
if !unique {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
user, err = db.CreateUser(ctx, tx, username, details.DiscordUser, db.NewAudit(r, nil))
|
user, err = registerUser(ctx, tx, username, details, cfg.RBAC, audit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.CreateUser")
|
return false, errors.Wrap(err, "registerUser")
|
||||||
}
|
}
|
||||||
err = user.UpdateDiscordToken(ctx, tx, details.Token)
|
err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.UpdateDiscordToken")
|
return false, errors.Wrap(err, "connectSlapID")
|
||||||
}
|
|
||||||
if shouldGrantAdmin(user, cfg.RBAC) {
|
|
||||||
err := ensureUserHasAdminRole(ctx, tx, user)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "ensureUserHasAdminRole")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
@@ -96,3 +95,62 @@ func Register(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerUser(ctx context.Context, tx bun.Tx,
|
||||||
|
username string, details *store.RegistrationSession,
|
||||||
|
rbac *rbac.Config, audit *db.AuditMeta,
|
||||||
|
) (*db.User, error) {
|
||||||
|
// Register the user
|
||||||
|
user, err := db.CreateUser(ctx, tx, username, details.DiscordUser, audit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "db.CreateUser")
|
||||||
|
}
|
||||||
|
err = user.UpdateDiscordToken(ctx, tx, details.Token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "db.UpdateDiscordToken")
|
||||||
|
}
|
||||||
|
err = user.ConnectPlayer(ctx, tx, audit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "db.ConnectPlayer")
|
||||||
|
}
|
||||||
|
// Check if they should be an admin
|
||||||
|
if shouldGrantAdmin(user, rbac) {
|
||||||
|
err := ensureUserHasAdminRole(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "ensureUserHasAdminRole")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User,
|
||||||
|
token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta,
|
||||||
|
) error {
|
||||||
|
// Attempt to setup their player/slapID from steam connection
|
||||||
|
// If fails due to no steam connection or no slapID, fail silently and proceed with registration
|
||||||
|
session, err := discord.NewOAuthSession(token)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "discord.NewOAuthSession")
|
||||||
|
}
|
||||||
|
steamID, err := session.GetSteamID()
|
||||||
|
if err != nil {
|
||||||
|
if err == discord.ErrNoSteam {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "session.GetSteamID")
|
||||||
|
}
|
||||||
|
slapID, err := slapAPI.GetSlapID(ctx, steamID)
|
||||||
|
if err != nil {
|
||||||
|
if err == slapshotapi.ErrNoSlapID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "slapAPI.GetSlapID")
|
||||||
|
}
|
||||||
|
// slapID exists, we can update their player connection
|
||||||
|
err = db.UpdatePlayerSlapID(ctx, tx, user.Player.ID, slapID, audit)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "db.UpdatePlayerSlapID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
@@ -41,6 +42,9 @@ func SeasonPage(
|
|||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if season.Type == db.SeasonTypeDraft.String() {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, "Draft"), http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
|
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func SeasonEditSubmit(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetSeason")
|
return false, errors.Wrap(err, "db.GetSeason")
|
||||||
}
|
}
|
||||||
err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAudit(r, nil))
|
err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "season.Update")
|
return false, errors.Wrap(err, "season.Update")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ func SeasonLeagueAddTeam(
|
|||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
seasonStr := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -36,7 +36,7 @@ func SeasonLeagueAddTeam(
|
|||||||
|
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonStr, leagueStr, teamID, db.NewAudit(r, nil))
|
team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonShortName, leagueShortName, teamID, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ func SeasonLeaguePage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, _, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -38,7 +38,7 @@ func SeasonLeaguePage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTab := season.GetDefaultTab()
|
defaultTab := sl.Season.GetDefaultTab()
|
||||||
redirectURL := fmt.Sprintf(
|
redirectURL := fmt.Sprintf(
|
||||||
"/seasons/%s/leagues/%s/%s",
|
"/seasons/%s/leagues/%s/%s",
|
||||||
seasonStr, leagueStr, defaultTab,
|
seasonStr, leagueStr, defaultTab,
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ func SeasonLeagueFinalsPage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -40,7 +39,7 @@ func SeasonLeagueFinalsPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinalsPage(sl.Season, sl.League), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFinals(), s, r, w)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ func SeasonLeagueFixturesPage(
|
|||||||
seasonShortName := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueShortName := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
|
var scheduleMap map[int]*db.FixtureSchedule
|
||||||
|
var resultMap map[int]*db.FixtureResult
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -36,15 +37,27 @@ func SeasonLeagueFixturesPage(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetFixtures")
|
return false, errors.Wrap(err, "db.GetFixtures")
|
||||||
}
|
}
|
||||||
|
fixtureIDs := make([]int, len(fixtures))
|
||||||
|
for i, f := range fixtures {
|
||||||
|
fixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
scheduleMap, err = db.GetAcceptedSchedulesForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures")
|
||||||
|
}
|
||||||
|
resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueFixtures(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,13 +70,12 @@ func SeasonLeagueManageFixturesPage(
|
|||||||
seasonShortName := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueShortName := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -76,7 +88,7 @@ func SeasonLeagueManageFixturesPage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +100,10 @@ func SeasonLeagueDeleteFixtures(
|
|||||||
seasonShortName := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueShortName := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
var fixtures []*db.Fixture
|
var fixtures []*db.Fixture
|
||||||
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAudit(r, nil))
|
err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures"))
|
respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures"))
|
||||||
@@ -100,7 +111,7 @@ func SeasonLeagueDeleteFixtures(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.DeleteAllFixtures")
|
return false, errors.Wrap(err, "db.DeleteAllFixtures")
|
||||||
}
|
}
|
||||||
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
sl, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixtures")
|
return false, errors.Wrap(err, "db.GetFixtures")
|
||||||
}
|
}
|
||||||
@@ -109,6 +120,6 @@ func SeasonLeagueDeleteFixtures(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueManageFixtures(sl.Season, sl.League, fixtures), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ func SeasonLeagueStatsPage(
|
|||||||
seasonStr := r.PathValue("season_short_name")
|
seasonStr := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var sl *db.SeasonLeague
|
||||||
var league *db.League
|
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -40,7 +39,7 @@ func SeasonLeagueStatsPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueStatsPage(sl.Season, sl.League), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueStats(), s, r, w)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,25 +23,46 @@ func SeasonLeagueTablePage(
|
|||||||
|
|
||||||
var season *db.Season
|
var season *db.Season
|
||||||
var league *db.League
|
var league *db.League
|
||||||
|
var leaderboard []*db.LeaderboardEntry
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
var teams []*db.Team
|
||||||
|
season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAllocatedFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
fixtureIDs := make([]int, len(fixtures))
|
||||||
|
for i, f := range fixtures {
|
||||||
|
fixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
leaderboard = db.ComputeLeaderboard(teams, fixtures, resultMap)
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTablePage(season, league, leaderboard), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueTable(), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTable(leaderboard), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
83
internal/handlers/season_league_team_detail.go
Normal file
83
internal/handlers/season_league_team_detail.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeasonLeagueTeamDetailPage renders the detail page for a team within a season league
|
||||||
|
func SeasonLeagueTeamDetailPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
teamIDStr := r.PathValue("team_id")
|
||||||
|
|
||||||
|
teamID, err := strconv.Atoi(teamIDStr)
|
||||||
|
if err != nil {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var twr *db.TeamWithRoster
|
||||||
|
var fixtures []*db.Fixture
|
||||||
|
var available []*db.Player
|
||||||
|
var scheduleMap map[int]*db.FixtureSchedule
|
||||||
|
var resultMap map[int]*db.FixtureResult
|
||||||
|
var playerStats []*db.AggregatedPlayerStats
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
twr, err = db.GetTeamRoster(ctx, tx, seasonShortName, leagueShortName, teamID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetTeamRoster")
|
||||||
|
}
|
||||||
|
fixtures, err = db.GetFixturesForTeam(ctx, tx, twr.Season.ID, twr.League.ID, twr.Team.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFixturesForTeam")
|
||||||
|
}
|
||||||
|
fixtureIDs := make([]int, len(fixtures))
|
||||||
|
for i, f := range fixtures {
|
||||||
|
fixtureIDs[i] = f.ID
|
||||||
|
}
|
||||||
|
scheduleMap, err = db.GetAcceptedSchedulesForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures")
|
||||||
|
}
|
||||||
|
resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
||||||
|
}
|
||||||
|
playerStats, err = db.GetAggregatedPlayerStatsForTeam(ctx, tx, teamID, fixtureIDs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetAggregatedPlayerStatsForTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err = db.GetPlayersNotOnTeam(ctx, tx, twr.Season.ID, twr.League.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record := db.ComputeTeamRecord(teamID, fixtures, resultMap)
|
||||||
|
renderSafely(seasonsview.SeasonLeagueTeamDetailPage(twr, fixtures, available, scheduleMap, resultMap, record, playerStats), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -25,10 +25,11 @@ func SeasonLeagueTeamsPage(
|
|||||||
var league *db.League
|
var league *db.League
|
||||||
var teams []*db.Team
|
var teams []*db.Team
|
||||||
var available []*db.Team
|
var available []*db.Team
|
||||||
|
var managers map[int]*db.Player
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
season, league, teams, err = db.GetSeasonLeagueWithTeams(ctx, tx, seasonStr, leagueStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
throw.NotFound(s, w, r, r.URL.Path)
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
@@ -45,15 +46,20 @@ func SeasonLeagueTeamsPage(
|
|||||||
return false, errors.Wrap(err, "db.GetList[Team]")
|
return false, errors.Wrap(err, "db.GetList[Team]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
managers, err = db.GetManagersByTeam(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetManagersByTeam")
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
renderSafely(seasonsview.SeasonLeagueTeamsPage(season, league, teams, available), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTeamsPage(season, league, teams, available, managers), s, r, w)
|
||||||
} else {
|
} else {
|
||||||
renderSafely(seasonsview.SeasonLeagueTeams(season, league, teams, available), s, r, w)
|
renderSafely(seasonsview.SeasonLeagueTeams(season, league, teams, available, managers), s, r, w)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ func SeasonAddLeague(
|
|||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
seasonStr := r.PathValue("season_short_name")
|
seasonShortName := r.PathValue("season_short_name")
|
||||||
leagueStr := r.PathValue("league_short_name")
|
leagueShortName := r.PathValue("league_short_name")
|
||||||
|
|
||||||
var season *db.Season
|
var season *db.Season
|
||||||
var allLeagues []*db.League
|
var allLeagues []*db.League
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
err := db.NewSeasonLeague(ctx, tx, seasonStr, leagueStr, db.NewAudit(r, nil))
|
err := db.NewSeasonLeague(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, err)
|
respond.BadRequest(w, err)
|
||||||
@@ -35,7 +35,7 @@ func SeasonAddLeague(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reload season with updated leagues
|
// Reload season with updated leagues
|
||||||
season, err = db.GetSeason(ctx, tx, seasonStr)
|
season, err = db.GetSeason(ctx, tx, seasonShortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetSeason")
|
return false, errors.Wrap(err, "db.GetSeason")
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ func SeasonRemoveLeague(
|
|||||||
}
|
}
|
||||||
return false, errors.Wrap(err, "db.GetSeason")
|
return false, errors.Wrap(err, "db.GetSeason")
|
||||||
}
|
}
|
||||||
err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAudit(r, nil))
|
err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsBadRequest(err) {
|
if db.IsBadRequest(err) {
|
||||||
respond.BadRequest(w, err)
|
respond.BadRequest(w, err)
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import (
|
|||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewSeason handles GET requests - redirects to the seasons list
|
||||||
|
// The form is now in a modal on the list page
|
||||||
func NewSeason(
|
func NewSeason(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
renderSafely(seasonsview.NewPage(), s, r, w)
|
respond.HXRedirect(w, "/seasons")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ func NewSeasonSubmit(
|
|||||||
MaxLength(6).MinLength(2).Value
|
MaxLength(6).MinLength(2).Value
|
||||||
version := getter.String("slap_version").
|
version := getter.String("slap_version").
|
||||||
TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value
|
TrimSpace().Required().AllowedValues([]string{"rebound", "slapshot1"}).Value
|
||||||
|
type_ := getter.String("type").
|
||||||
|
TrimSpace().Required().AllowedValues([]string{"regular", "draft"}).Value
|
||||||
format := timefmt.NewBuilder().
|
format := timefmt.NewBuilder().
|
||||||
DayNumeric2().Slash().
|
DayNumeric2().Slash().
|
||||||
MonthNumeric2().Slash().
|
MonthNumeric2().Slash().
|
||||||
@@ -52,7 +56,6 @@ func NewSeasonSubmit(
|
|||||||
|
|
||||||
nameUnique := false
|
nameUnique := false
|
||||||
shortNameUnique := false
|
shortNameUnique := false
|
||||||
var season *db.Season
|
|
||||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
nameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "name", name)
|
nameUnique, err = db.IsUnique(ctx, tx, (*db.Season)(nil), "name", name)
|
||||||
@@ -66,7 +69,7 @@ func NewSeasonSubmit(
|
|||||||
if !nameUnique || !shortNameUnique {
|
if !nameUnique || !shortNameUnique {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
season, err = db.NewSeason(ctx, tx, name, version, shortname, start, db.NewAudit(r, nil))
|
_, err = db.NewSeason(ctx, tx, name, version, shortname, type_, start, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.NewSeason")
|
return false, errors.Wrap(err, "db.NewSeason")
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,26 @@ func NewSeasonSubmit(
|
|||||||
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
|
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond.HXRedirect(w, "/seasons/%s", season.ShortName)
|
|
||||||
notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
|
// Return the updated seasons list
|
||||||
|
pageOpts := &db.PageOpts{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 10,
|
||||||
|
Order: bun.OrderDesc,
|
||||||
|
OrderBy: "start_date",
|
||||||
|
}
|
||||||
|
var seasons *db.List[db.Season]
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
seasons, err = db.ListSeasons(ctx, tx, pageOpts)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.ListSeasons")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderSafely(seasonsview.SeasonsList(seasons), s, r, w)
|
||||||
|
notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
89
internal/handlers/team_roster_manage.go
Normal file
89
internal/handlers/team_roster_manage.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
|
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManageTeamRoster handles saving a full team roster (manager + players)
|
||||||
|
func ManageTeamRoster(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
respond.BadRequest(w, errors.New("failed to parse form"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seasonID := getter.Int("season_id").Required().Value
|
||||||
|
leagueID := getter.Int("league_id").Required().Value
|
||||||
|
teamID := getter.Int("team_id").Required().Value
|
||||||
|
managerID := getter.Int("manager_id").Required().Value
|
||||||
|
playerIDs := getter.IntList("player_ids").Values()
|
||||||
|
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
respond.BadRequest(w, errors.New("invalid form data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write transaction: manage the roster
|
||||||
|
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
err := db.ManageTeamRoster(ctx, tx, seasonID, leagueID, teamID, managerID, playerIDs, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.ManageTeamRoster")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch updated data for HTMX swap
|
||||||
|
var twr *db.TeamWithRoster
|
||||||
|
var available []*db.Player
|
||||||
|
|
||||||
|
if !conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// We need season/league short names to call GetTeamRoster
|
||||||
|
season, err := db.GetByID[db.Season](tx, seasonID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetSeason")
|
||||||
|
}
|
||||||
|
league, err := db.GetByID[db.League](tx, leagueID).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
twr, err = db.GetTeamRoster(ctx, tx, season.ShortName, league.ShortName, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetTeamRoster")
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err = db.GetPlayersNotOnTeam(ctx, tx, seasonID, leagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with HTMX swap of the roster section
|
||||||
|
w.Header().Set("HX-Retarget", "#team-roster-section")
|
||||||
|
w.Header().Set("HX-Reswap", "outerHTML")
|
||||||
|
notify.Success(s, w, r, "Roster Updated", "Team roster has been saved successfully.", nil)
|
||||||
|
renderSafely(seasonsview.TeamRosterSection(twr, available), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ func NewTeamSubmit(
|
|||||||
}
|
}
|
||||||
name := getter.String("name").
|
name := getter.String("name").
|
||||||
TrimSpace().Required().
|
TrimSpace().Required().
|
||||||
MaxLength(25).MinLength(3).Value
|
MaxLength(50).MinLength(3).Value
|
||||||
shortName := getter.String("short_name").
|
shortName := getter.String("short_name").
|
||||||
TrimSpace().Required().ToUpper().
|
TrimSpace().Required().ToUpper().
|
||||||
MaxLength(3).MinLength(3).Value
|
MaxLength(3).MinLength(3).Value
|
||||||
@@ -71,7 +71,7 @@ func NewTeamSubmit(
|
|||||||
if !nameUnique || !shortNameComboUnique {
|
if !nameUnique || !shortNameComboUnique {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
_, err = db.NewTeam(ctx, tx, name, shortName, altShortName, color, db.NewAudit(r, nil))
|
_, err = db.NewTeam(ctx, tx, name, shortName, altShortName, color, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.NewTeam")
|
return false, errors.Wrap(err, "db.NewTeam")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const (
|
|||||||
TeamsUpdate Permission = "teams.update"
|
TeamsUpdate Permission = "teams.update"
|
||||||
TeamsDelete Permission = "teams.delete"
|
TeamsDelete Permission = "teams.delete"
|
||||||
TeamsAddToLeague Permission = "teams.add_to_league"
|
TeamsAddToLeague Permission = "teams.add_to_league"
|
||||||
|
TeamsManagePlayers Permission = "teams.manage_players"
|
||||||
|
|
||||||
// Users permissions
|
// Users permissions
|
||||||
UsersUpdate Permission = "users.update"
|
UsersUpdate Permission = "users.update"
|
||||||
@@ -38,4 +39,8 @@ const (
|
|||||||
FixturesManage Permission = "fixtures.manage"
|
FixturesManage Permission = "fixtures.manage"
|
||||||
FixturesCreate Permission = "fixtures.create"
|
FixturesCreate Permission = "fixtures.create"
|
||||||
FixturesDelete Permission = "fixtures.delete"
|
FixturesDelete Permission = "fixtures.delete"
|
||||||
|
|
||||||
|
// Free Agent permissions
|
||||||
|
FreeAgentsAdd Permission = "free_agents.add"
|
||||||
|
FreeAgentsRemove Permission = "free_agents.remove"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ func LoadPreviewRoleMiddleware(s *hws.Server, conn *db.DB) func(http.Handler) ht
|
|||||||
|
|
||||||
user := db.CurrentUser(r.Context())
|
user := db.CurrentUser(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
// User not logged in,
|
// User not logged in
|
||||||
ClearPreviewRoleCookie(w)
|
// Auth middleware skips on certain routes like CSS files so even
|
||||||
|
// if user IS logged in, this will trigger on those routes,
|
||||||
|
// so we just pass the request on and do nothing.
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addRoutes(
|
func addRoutes(
|
||||||
@@ -25,6 +26,7 @@ func addRoutes(
|
|||||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||||
store *store.Store,
|
store *store.Store,
|
||||||
discordAPI *discord.APIClient,
|
discordAPI *discord.APIClient,
|
||||||
|
slapAPI *slapshotapi.SlapAPI,
|
||||||
perms *rbac.Checker,
|
perms *rbac.Checker,
|
||||||
) error {
|
) error {
|
||||||
// Create the routes
|
// Create the routes
|
||||||
@@ -55,7 +57,7 @@ func addRoutes(
|
|||||||
{
|
{
|
||||||
Path: "/register",
|
Path: "/register",
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: auth.LogoutReq(handlers.Register(s, auth, conn, cfg, store)),
|
Handler: auth.LogoutReq(handlers.Register(s, auth, conn, slapAPI, cfg, store)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: "/logout",
|
Path: "/logout",
|
||||||
@@ -115,6 +117,11 @@ func addRoutes(
|
|||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: handlers.SeasonLeagueTeamsPage(s, conn),
|
Handler: handlers.SeasonLeagueTeamsPage(s, conn),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/teams/{team_id}",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.SeasonLeagueTeamDetailPage(s, conn),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/stats",
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/stats",
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
@@ -140,6 +147,22 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)),
|
||||||
},
|
},
|
||||||
|
// Free agent routes
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.FreeAgentsListPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/register",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FreeAgentsAdd)(handlers.RegisterFreeAgent(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/unregister",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FreeAgentsRemove)(handlers.UnregisterFreeAgent(s, conn)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
leagueRoutes := []hws.Route{
|
leagueRoutes := []hws.Route{
|
||||||
@@ -181,11 +204,89 @@ func addRoutes(
|
|||||||
Method: hws.MethodDELETE,
|
Method: hws.MethodDELETE,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.FixtureDetailPage(s, conn),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/fixtures/{fixture_id}",
|
Path: "/fixtures/{fixture_id}",
|
||||||
Method: hws.MethodDELETE,
|
Method: hws.MethodDELETE,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
|
||||||
},
|
},
|
||||||
|
// Fixture scheduling routes
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.ProposeSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/accept",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.AcceptSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/reject",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.RejectSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule/{schedule_id}/withdraw",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.WithdrawSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule/postpone",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.PostponeSchedule(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule/reschedule",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.RescheduleFixture(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/schedule/cancel",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)),
|
||||||
|
},
|
||||||
|
// Fixture free agent nomination routes
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/free-agents/nominate",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.NominateFreeAgentHandler(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/free-agents/{player_id}/remove",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.RemoveFreeAgentNominationHandler(s, conn),
|
||||||
|
},
|
||||||
|
// Match result management routes (all require fixtures.manage permission)
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/results/upload",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UploadMatchLogsPage(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/results/upload",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UploadMatchLogs(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/results/review",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.ReviewMatchResult(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/results/finalize",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.FinalizeMatchResult(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/results/discard",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.DiscardMatchResult(s, conn)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
teamRoutes := []hws.Route{
|
teamRoutes := []hws.Route{
|
||||||
@@ -204,6 +305,11 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/teams/manage_roster",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.TeamsManagePlayers)(handlers.ManageTeamRoster(s, conn)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
htmxRoutes := []hws.Route{
|
htmxRoutes := []hws.Route{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Setup(
|
func Setup(
|
||||||
@@ -24,6 +25,7 @@ func Setup(
|
|||||||
conn *db.DB,
|
conn *db.DB,
|
||||||
store *store.Store,
|
store *store.Store,
|
||||||
discordAPI *discord.APIClient,
|
discordAPI *discord.APIClient,
|
||||||
|
slapAPI *slapshotapi.SlapAPI,
|
||||||
) (server *hws.Server, err error) {
|
) (server *hws.Server, err error) {
|
||||||
if staticFS == nil {
|
if staticFS == nil {
|
||||||
return nil, errors.New("No filesystem provided")
|
return nil, errors.New("No filesystem provided")
|
||||||
@@ -67,7 +69,7 @@ func Setup(
|
|||||||
return nil, errors.Wrap(err, "rbac.NewChecker")
|
return nil, errors.Wrap(err, "rbac.NewChecker")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = addRoutes(httpServer, &fs, cfg, conn, auth, store, discordAPI, perms)
|
err = addRoutes(httpServer, &fs, cfg, conn, auth, store, discordAPI, slapAPI, perms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "addRoutes")
|
return nil, errors.Wrap(err, "addRoutes")
|
||||||
}
|
}
|
||||||
|
|||||||
24
internal/validation/boolfield.go
Normal file
24
internal/validation/boolfield.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BoolField struct {
|
||||||
|
FieldBase
|
||||||
|
Value bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoolField(key string, g Getter) *BoolField {
|
||||||
|
raw := g.Get(key)
|
||||||
|
val, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
g.AddCheck(newFailedCheck("Invalid boolean value",
|
||||||
|
fmt.Sprintf("Field %s requires a boolean value, %s given", key, raw)))
|
||||||
|
}
|
||||||
|
return &BoolField{
|
||||||
|
newField(key, g),
|
||||||
|
val,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package validation
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
@@ -28,22 +28,39 @@ func (f *FormGetter) Get(key string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FormGetter) GetList(key string) []string {
|
func (f *FormGetter) GetList(key string) []string {
|
||||||
return strings.Split(f.Get(key), ",")
|
if f.r.Form == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values, ok := f.r.Form[key]
|
||||||
|
if !ok || len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Support both comma-separated single values and multiple form fields
|
||||||
|
if len(values) == 1 {
|
||||||
|
return strings.Split(values[0], ",")
|
||||||
|
}
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FormGetter) GetMaps(key string) []map[string]string {
|
func (f *FormGetter) GetMaps(key string) []map[string]string {
|
||||||
var result []map[string]string
|
results := map[string]map[string]string{}
|
||||||
for key, values := range f.r.Form {
|
re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z_]+)\\]")
|
||||||
re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z]+)\\]")
|
for k, v := range f.r.Form {
|
||||||
matches := re.FindStringSubmatch(key)
|
matches := re.FindStringSubmatch(k)
|
||||||
if len(matches) >= 3 {
|
if len(matches) >= 3 {
|
||||||
index, _ := strconv.Atoi(matches[1])
|
realKey := matches[1]
|
||||||
for index >= len(result) {
|
field := matches[2]
|
||||||
result = append(result, map[string]string{})
|
value := strings.Join(v, ",")
|
||||||
|
if _, exists := results[realKey]; !exists {
|
||||||
|
results[realKey] = map[string]string{}
|
||||||
}
|
}
|
||||||
result[index][matches[2]] = values[0]
|
results[realKey][field] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result := []map[string]string{}
|
||||||
|
for _, v := range results {
|
||||||
|
result = append(result, v)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +84,18 @@ func (f *FormGetter) Int(key string) *IntField {
|
|||||||
return newIntField(key, f)
|
return newIntField(key, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FormGetter) Bool(key string) *BoolField {
|
||||||
|
return newBoolField(key, f)
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField {
|
func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField {
|
||||||
return newTimeField(key, format, f)
|
return newTimeField(key, format, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FormGetter) TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField {
|
||||||
|
return newTimeFieldInLocation(key, format, loc, f)
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FormGetter) StringList(key string) *StringList {
|
func (f *FormGetter) StringList(key string) *StringList {
|
||||||
return newStringList(key, f)
|
return newStringList(key, f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ func (q *QueryGetter) Int(key string) *IntField {
|
|||||||
return newIntField(key, q)
|
return newIntField(key, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (q *QueryGetter) Bool(key string) *BoolField {
|
||||||
|
return newBoolField(key, q)
|
||||||
|
}
|
||||||
|
|
||||||
func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField {
|
func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField {
|
||||||
return newTimeField(key, format, q)
|
return newTimeField(key, format, q)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ func newTimeField(key string, format *timefmt.Format, g Getter) *TimeField {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTimeFieldInLocation(key string, format *timefmt.Format, loc *time.Location, g Getter) *TimeField {
|
||||||
|
raw := g.Get(key)
|
||||||
|
var startDate time.Time
|
||||||
|
if raw != "" {
|
||||||
|
var err error
|
||||||
|
startDate, err = format.ParseInLocation(raw, loc)
|
||||||
|
if err != nil {
|
||||||
|
g.AddCheck(newFailedCheck(
|
||||||
|
"Invalid date/time format",
|
||||||
|
fmt.Sprintf("%s should be in format %s", key, format.LDML()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &TimeField{
|
||||||
|
Value: startDate,
|
||||||
|
FieldBase: newField(key, g),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *TimeField) Required() *TimeField {
|
func (t *TimeField) Required() *TimeField {
|
||||||
if t.Value.IsZero() {
|
if t.Value.IsZero() {
|
||||||
t.getter.AddCheck(newFailedCheck(
|
t.getter.AddCheck(newFailedCheck(
|
||||||
@@ -48,3 +67,23 @@ func (t *TimeField) Optional() *TimeField {
|
|||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *TimeField) Before(limit time.Time) *TimeField {
|
||||||
|
if !t.Value.Before(limit) {
|
||||||
|
t.getter.AddCheck(newFailedCheck(
|
||||||
|
"Date/Time invalid",
|
||||||
|
fmt.Sprintf("%s must be before %s", t.Key, limit),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TimeField) After(limit time.Time) *TimeField {
|
||||||
|
if !t.Value.After(limit) {
|
||||||
|
t.getter.AddCheck(newFailedCheck(
|
||||||
|
"Date/Time invalid",
|
||||||
|
fmt.Sprintf("%s must be after %s", t.Key, limit),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Getter interface {
|
|||||||
AddCheck(check *ValidationRule)
|
AddCheck(check *ValidationRule)
|
||||||
String(key string) *StringField
|
String(key string) *StringField
|
||||||
Int(key string) *IntField
|
Int(key string) *IntField
|
||||||
|
Bool(key string) *BoolField
|
||||||
Time(key string, format *timefmt.Format) *TimeField
|
Time(key string, format *timefmt.Format) *TimeField
|
||||||
StringList(key string) *StringList
|
StringList(key string) *StringList
|
||||||
IntList(key string) *IntList
|
IntList(key string) *IntList
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ templ Layout(title string) {
|
|||||||
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
||||||
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
||||||
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
|
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
|
||||||
|
<script src="/static/js/localtime.js" defer></script>
|
||||||
if devInfo.HTMXLog {
|
if devInfo.HTMXLog {
|
||||||
<script>
|
<script>
|
||||||
htmx.logAll();
|
htmx.logAll();
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ templ SeasonDetails(season *db.Season, leaguesWithTeams []db.LeagueWithTeams) {
|
|||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name }</h1>
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ season.Name }</h1>
|
||||||
|
<span class="text-lg font-mono text-subtext0 bg-surface1 px-2 py-0.5 rounded">{ season.ShortName }</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
|
@SeasonTypeBadge(season.Type)
|
||||||
{ season.ShortName }
|
|
||||||
</span>
|
|
||||||
@SlapVersionBadge(season.SlapVersion)
|
@SlapVersionBadge(season.SlapVersion)
|
||||||
@StatusBadge(season, false, false)
|
@StatusBadge(season, false, false)
|
||||||
</div>
|
</div>
|
||||||
@@ -221,3 +222,16 @@ templ SlapVersionBadge(version string) {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ SeasonTypeBadge(type_ string) {
|
||||||
|
switch type_ {
|
||||||
|
case "regular":
|
||||||
|
<span class="inline-block bg-sapphire px-3 py-1 rounded-full text-sm font-semibold text-mantle">
|
||||||
|
Regular
|
||||||
|
</span>
|
||||||
|
case "draft":
|
||||||
|
<span class="inline-block bg-mauve px-3 py-1 rounded-full text-sm font-semibold text-mantle">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1261
internal/view/seasonsview/fixture_detail.templ
Normal file
1261
internal/view/seasonsview/fixture_detail.templ
Normal file
File diff suppressed because it is too large
Load Diff
260
internal/view/seasonsview/fixture_review_result.templ
Normal file
260
internal/view/seasonsview/fixture_review_result.templ
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ FixtureReviewResultPage(
|
||||||
|
fixture *db.Fixture,
|
||||||
|
result *db.FixtureResult,
|
||||||
|
unmappedPlayers []string,
|
||||||
|
unnominatedFreeAgents []FreeAgentWarning,
|
||||||
|
) {
|
||||||
|
{{
|
||||||
|
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("Review Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||||
|
<div class="max-w-screen-xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-text mb-1">Review Match Result</h1>
|
||||||
|
<p class="text-sm text-subtext1">
|
||||||
|
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||||
|
<span class="text-subtext0 ml-1">
|
||||||
|
Round { fmt.Sprint(fixture.Round) }
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(backURL) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Fixture
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Warnings Section -->
|
||||||
|
if result.TamperingDetected || len(unmappedPlayers) > 0 || len(unnominatedFreeAgents) > 0 {
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
if result.TamperingDetected && result.TamperingReason != nil {
|
||||||
|
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
|
||||||
|
<p class="text-red/60 text-xs mt-2">
|
||||||
|
This does not block finalization but should be reviewed carefully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if len(unnominatedFreeAgents) > 0 {
|
||||||
|
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-yellow font-bold text-sm">⚠ Free Agent Nomination Issues</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-yellow/80 text-sm mb-2">
|
||||||
|
The following free agents have nomination issues that should be reviewed before finalizing.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||||
|
for _, fa := range unnominatedFreeAgents {
|
||||||
|
<li>
|
||||||
|
<span class="text-yellow font-medium">{ fa.Name }</span>
|
||||||
|
<span class="text-yellow/60"> — { fa.Reason }</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if len(unmappedPlayers) > 0 {
|
||||||
|
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-yellow/80 text-sm mb-2">
|
||||||
|
The following players could not be matched to registered players.
|
||||||
|
They may be free agents or have unregistered Slapshot IDs.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||||
|
for _, p := range unmappedPlayers {
|
||||||
|
<li>{ p }</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Score Overview -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Score</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-center gap-8 py-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-subtext0 mb-1">{ fixture.HomeTeam.Name }</p>
|
||||||
|
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl text-subtext0 font-light">—</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-subtext0 mb-1">{ fixture.AwayTeam.Name }</p>
|
||||||
|
<p class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-4 mt-2 text-xs text-subtext1">
|
||||||
|
if result.Arena != "" {
|
||||||
|
<span>{ result.Arena }</span>
|
||||||
|
}
|
||||||
|
if result.EndReason != "" {
|
||||||
|
<span>{ result.EndReason }</span>
|
||||||
|
}
|
||||||
|
<span>
|
||||||
|
Winner:
|
||||||
|
if result.Winner == "home" {
|
||||||
|
{ fixture.HomeTeam.Name }
|
||||||
|
} else if result.Winner == "away" {
|
||||||
|
{ fixture.AwayTeam.Name }
|
||||||
|
} else {
|
||||||
|
Draw
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Player Stats Tables -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
@reviewTeamStats(fixture.HomeTeam, result, "home")
|
||||||
|
@reviewTeamStats(fixture.AwayTeam, result, "away")
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Actions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/fixtures/%d/results/finalize", fixture.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-3 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
|
>
|
||||||
|
Finalize Result
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click={ fmt.Sprintf("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Discard Result', message: 'Are you sure you want to discard this result? You will need to re-upload the match logs.', action: () => htmx.ajax('POST', '/fixtures/%d/results/discard', { swap: 'none' }) } }))", fixture.ID) }
|
||||||
|
class="px-6 py-3 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
|
>
|
||||||
|
Discard & Re-upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
|
||||||
|
{{
|
||||||
|
// Collect unique players for this team across all periods
|
||||||
|
// We'll show the period 3 (final/cumulative) stats
|
||||||
|
type playerStat struct {
|
||||||
|
Username string
|
||||||
|
PlayerID *int
|
||||||
|
Stats *db.FixtureResultPlayerStats
|
||||||
|
}
|
||||||
|
finalStats := []*playerStat{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
// Find period 3 stats for this team (cumulative)
|
||||||
|
for _, ps := range result.PlayerStats {
|
||||||
|
if ps.Team == side && ps.PeriodNum == 3 {
|
||||||
|
if !seen[ps.PlayerGameUserID] {
|
||||||
|
seen[ps.PlayerGameUserID] = true
|
||||||
|
finalStats = append(finalStats, &playerStat{
|
||||||
|
Username: ps.PlayerUsername,
|
||||||
|
PlayerID: ps.PlayerID,
|
||||||
|
Stats: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-md font-bold text-text">
|
||||||
|
if side == "home" {
|
||||||
|
Home —
|
||||||
|
} else {
|
||||||
|
Away —
|
||||||
|
}
|
||||||
|
{ team.Name }
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-surface0 border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, ps := range finalStats {
|
||||||
|
<tr class="hover:bg-surface0 transition-colors">
|
||||||
|
<td class="px-3 py-2 text-sm text-text">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
{ ps.Username }
|
||||||
|
if ps.PlayerID == nil {
|
||||||
|
<span class="text-yellow text-xs" title="Unmapped player">?</span>
|
||||||
|
}
|
||||||
|
if ps.Stats.IsFreeAgent {
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FREE AGENT
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Saves) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Shots) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Blocks) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Passes) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Score) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
if len(finalStats) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="px-3 py-4 text-center text-sm text-subtext1">
|
||||||
|
No player stats recorded
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtrStr(v *int) string {
|
||||||
|
if v == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprint(*v)
|
||||||
|
}
|
||||||
118
internal/view/seasonsview/fixture_upload_result.templ
Normal file
118
internal/view/seasonsview/fixture_upload_result.templ
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ FixtureUploadResultPage(fixture *db.Fixture) {
|
||||||
|
{{
|
||||||
|
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("Upload Result — %s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||||||
|
<div class="max-w-screen-md mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-6">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-text mb-1">Upload Match Logs</h1>
|
||||||
|
<p class="text-sm text-subtext1">
|
||||||
|
{ fixture.HomeTeam.Name } vs { fixture.AwayTeam.Name }
|
||||||
|
<span class="text-subtext0 ml-1">
|
||||||
|
Round { fmt.Sprint(fixture.Round) }
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(backURL) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Upload Form -->
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||||||
|
<h2 class="text-lg font-bold text-text">Match Log Files</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-sm text-subtext1 mb-6">
|
||||||
|
Upload the 3 period match log JSON files. Each file corresponds to one period of the match.
|
||||||
|
The files will be validated for consistency.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
hx-encoding="multipart/form-data"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Period 1 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text mb-2">
|
||||||
|
Period 1
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="period_1"
|
||||||
|
accept=".json"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||||
|
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Period 2 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text mb-2">
|
||||||
|
Period 2
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="period_2"
|
||||||
|
accept=".json"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||||
|
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Period 3 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text mb-2">
|
||||||
|
Period 3
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="period_3"
|
||||||
|
accept=".json"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
file:mr-4 file:py-1 file:px-3 file:rounded file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-blue file:text-mantle
|
||||||
|
file:hover:bg-blue/80 file:hover:cursor-pointer file:transition
|
||||||
|
focus:border-blue focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-4 py-3 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||||
|
font-medium transition hover:cursor-pointer text-lg"
|
||||||
|
>
|
||||||
|
Upload & Validate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,14 @@ import "git.haelnorr.com/h/oslstats/internal/permissions"
|
|||||||
|
|
||||||
templ ListPage(seasons *db.List[db.Season]) {
|
templ ListPage(seasons *db.List[db.Season]) {
|
||||||
@baseview.Layout("Seasons") {
|
@baseview.Layout("Seasons") {
|
||||||
|
<!-- Flatpickr CSS -->
|
||||||
|
<link rel="stylesheet" href="/static/vendored/flatpickr@4.6.13.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/flatpickr-catppuccin.css"/>
|
||||||
|
<!-- Flatpickr JS -->
|
||||||
|
<script src="/static/vendored/flatpickr@4.6.13.min.js"></script>
|
||||||
<div class="max-w-screen-2xl mx-auto px-2">
|
<div class="max-w-screen-2xl mx-auto px-2">
|
||||||
@SeasonsList(seasons)
|
@SeasonsList(seasons)
|
||||||
|
@NewSeasonModal()
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,11 +70,12 @@ templ SeasonsList(seasons *db.List[db.Season]) {
|
|||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<span class="text-3xl font-bold">Seasons</span>
|
<span class="text-3xl font-bold">Seasons</span>
|
||||||
if canAddSeason {
|
if canAddSeason {
|
||||||
<a
|
<button
|
||||||
href="/seasons/new"
|
type="button"
|
||||||
|
@click="$dispatch('open-new-season-modal')"
|
||||||
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
|
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
|
||||||
bg-green hover:bg-green/75 text-mantle transition"
|
bg-green hover:bg-green/75 text-mantle transition"
|
||||||
>Add season</a>
|
>Add season</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@sort.Dropdown(seasons.PageOpts, sortOpts)
|
@sort.Dropdown(seasons.PageOpts, sortOpts)
|
||||||
|
|||||||
43
internal/view/seasonsview/localtime.templ
Normal file
43
internal/view/seasonsview/localtime.templ
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// formatISO returns an ISO 8601 UTC string for use in <time datetime="...">.
|
||||||
|
func formatISO(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatISOUnix returns an ISO 8601 UTC string from a Unix timestamp.
|
||||||
|
func formatISOUnix(unix int64) string {
|
||||||
|
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localtime renders a <time> element that will be formatted client-side
|
||||||
|
// in the user's local timezone. The format parameter maps to data-localtime:
|
||||||
|
// "date" → "Mon 2 Jan 2026"
|
||||||
|
// "time" → "3:04 PM"
|
||||||
|
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
|
||||||
|
// "short" → "Mon 2 Jan 3:04 PM"
|
||||||
|
// "histdate" → "2 Jan 2006 15:04"
|
||||||
|
templ localtime(t *time.Time, format string) {
|
||||||
|
if t != nil {
|
||||||
|
<time datetime={ formatISO(t) } data-localtime={ format }>
|
||||||
|
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
|
||||||
|
</time>
|
||||||
|
} else {
|
||||||
|
No time set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localtimeUnix renders a <time> element from a Unix timestamp.
|
||||||
|
templ localtimeUnix(unix int64, format string) {
|
||||||
|
{{
|
||||||
|
t := time.Unix(unix, 0)
|
||||||
|
}}
|
||||||
|
<time datetime={ formatISOUnix(unix) } data-localtime={ format }>
|
||||||
|
{ t.UTC().Format("2 Jan 2006 15:04 UTC") }
|
||||||
|
</time>
|
||||||
|
}
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/datepicker"
|
|
||||||
|
|
||||||
templ NewForm() {
|
|
||||||
<form
|
|
||||||
hx-post="/seasons/new"
|
|
||||||
hx-swap="none"
|
|
||||||
x-data={ templ.JSFuncCall("newSeasonFormData").CallInline }
|
|
||||||
@submit="handleSubmit()"
|
|
||||||
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful && $event.detail.xhr.status !== 409) { isSubmitting=false; buttonText='Create Season'; generalError='An error occurred. Please try again.'; }"
|
|
||||||
>
|
|
||||||
<script>
|
|
||||||
function newSeasonFormData() {
|
|
||||||
return {
|
|
||||||
canSubmit: false,
|
|
||||||
buttonText: "Create Season",
|
|
||||||
// Name validation state
|
|
||||||
nameError: "",
|
|
||||||
nameIsChecking: false,
|
|
||||||
nameIsUnique: false,
|
|
||||||
nameIsEmpty: true,
|
|
||||||
// Short name validation state
|
|
||||||
shortNameError: "",
|
|
||||||
shortNameIsChecking: false,
|
|
||||||
shortNameIsUnique: false,
|
|
||||||
shortNameIsEmpty: true,
|
|
||||||
// Date validation state
|
|
||||||
dateError: "",
|
|
||||||
dateIsEmpty: true,
|
|
||||||
// Form state
|
|
||||||
isSubmitting: false,
|
|
||||||
generalError: "",
|
|
||||||
submitTimeout: null,
|
|
||||||
// Reset name errors
|
|
||||||
resetNameErr() {
|
|
||||||
this.nameError = "";
|
|
||||||
this.nameIsChecking = false;
|
|
||||||
this.nameIsUnique = false;
|
|
||||||
},
|
|
||||||
// Reset short name errors
|
|
||||||
resetShortNameErr() {
|
|
||||||
this.shortNameError = "";
|
|
||||||
this.shortNameIsChecking = false;
|
|
||||||
this.shortNameIsUnique = false;
|
|
||||||
},
|
|
||||||
// Reset date errors
|
|
||||||
resetDateErr() {
|
|
||||||
this.dateError = "";
|
|
||||||
},
|
|
||||||
// Check if form can be submitted
|
|
||||||
updateCanSubmit() {
|
|
||||||
this.canSubmit =
|
|
||||||
!this.nameIsEmpty &&
|
|
||||||
this.nameIsUnique &&
|
|
||||||
!this.nameIsChecking &&
|
|
||||||
!this.shortNameIsEmpty &&
|
|
||||||
this.shortNameIsUnique &&
|
|
||||||
!this.shortNameIsChecking &&
|
|
||||||
!this.dateIsEmpty;
|
|
||||||
},
|
|
||||||
// Handle form submission
|
|
||||||
handleSubmit() {
|
|
||||||
this.isSubmitting = true;
|
|
||||||
this.buttonText = "Creating...";
|
|
||||||
this.generalError = "";
|
|
||||||
// Set timeout for 10 seconds
|
|
||||||
this.submitTimeout = setTimeout(() => {
|
|
||||||
this.isSubmitting = false;
|
|
||||||
this.buttonText = "Create Season";
|
|
||||||
this.generalError = "Request timed out. Please try again.";
|
|
||||||
}, 10000);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<div class="grid gap-y-5">
|
|
||||||
<!-- Name Field -->
|
|
||||||
<div>
|
|
||||||
<label for="name" class="block text-sm font-medium mb-2">Season Name</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
maxlength="20"
|
|
||||||
x-bind:class="{
|
|
||||||
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
|
|
||||||
'border-overlay0 focus:border-blue': !nameIsUnique && !nameError,
|
|
||||||
'border-green focus:border-green': nameIsUnique && !nameIsChecking && !nameError,
|
|
||||||
'border-red focus:border-red': nameError && !nameIsChecking && !isSubmitting
|
|
||||||
}"
|
|
||||||
required
|
|
||||||
placeholder="e.g. Season 1"
|
|
||||||
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Season name is required'; nameIsUnique=false; } updateCanSubmit();"
|
|
||||||
hx-post="/htmx/isseasonnameunique"
|
|
||||||
hx-trigger="input changed delay:500ms"
|
|
||||||
hx-swap="none"
|
|
||||||
@htmx:before-request="if($el.value.trim() === '') { nameIsEmpty=true; return; } nameIsEmpty=false; nameIsChecking=true; nameIsUnique=false; nameError=''; updateCanSubmit();"
|
|
||||||
@htmx:after-request="nameIsChecking=false; if($event.detail.successful) { nameIsUnique=true; } else if($event.detail.xhr.status === 409) { nameError='This season name is already taken'; nameIsUnique=false; } updateCanSubmit();"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-subtext1 mt-1">Maximum 20 characters</p>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-center text-xs text-red mt-2"
|
|
||||||
x-show="nameError && !isSubmitting"
|
|
||||||
x-cloak
|
|
||||||
x-text="nameError"
|
|
||||||
></p>
|
|
||||||
</div>
|
|
||||||
<!-- Short Name Field -->
|
|
||||||
<div>
|
|
||||||
<label for="short_name" class="block text-sm font-medium mb-2">Short Name</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="short_name"
|
|
||||||
name="short_name"
|
|
||||||
maxlength="6"
|
|
||||||
x-bind:class="{
|
|
||||||
'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none uppercase': true,
|
|
||||||
'border-overlay0 focus:border-blue': !shortNameIsUnique && !shortNameError,
|
|
||||||
'border-green focus:border-green': shortNameIsUnique && !shortNameIsChecking && !shortNameError,
|
|
||||||
'border-red focus:border-red': shortNameError && !shortNameIsChecking && !isSubmitting
|
|
||||||
}"
|
|
||||||
required
|
|
||||||
placeholder="e.g. S1"
|
|
||||||
pattern="[A-Z0-9]+"
|
|
||||||
@input="
|
|
||||||
let val = $el.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
||||||
$el.value = val;
|
|
||||||
resetShortNameErr();
|
|
||||||
shortNameIsEmpty = val.trim() === '';
|
|
||||||
if(shortNameIsEmpty) {
|
|
||||||
shortNameError='Short name is required';
|
|
||||||
shortNameIsUnique=false;
|
|
||||||
}
|
|
||||||
updateCanSubmit();
|
|
||||||
"
|
|
||||||
hx-post="/htmx/isseasonshortnameunique"
|
|
||||||
hx-trigger="input changed delay:500ms"
|
|
||||||
hx-swap="none"
|
|
||||||
@htmx:before-request="if($el.value.trim() === '') { shortNameIsEmpty=true; return; } shortNameIsEmpty=false; shortNameIsChecking=true; shortNameIsUnique=false; shortNameError=''; updateCanSubmit();"
|
|
||||||
@htmx:after-request="shortNameIsChecking=false; if($event.detail.successful) { shortNameIsUnique=true; } else if($event.detail.xhr.status === 409) { shortNameError='This short name is already taken'; shortNameIsUnique=false; } updateCanSubmit();"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-subtext1 mt-1">Maximum 6 characters, alphanumeric only (auto-capitalized)</p>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-center text-xs text-red mt-2"
|
|
||||||
x-show="shortNameError && !isSubmitting"
|
|
||||||
x-cloak
|
|
||||||
x-text="shortNameError"
|
|
||||||
></p>
|
|
||||||
</div>
|
|
||||||
<!-- Slap Version Field -->
|
|
||||||
<div>
|
|
||||||
<label for="slap_version" class="block text-sm font-medium mb-2">Slap Version</label>
|
|
||||||
<select
|
|
||||||
id="slap_version"
|
|
||||||
name="slap_version"
|
|
||||||
class="py-3 px-4 block w-full rounded-lg text-sm bg-base border-2 border-overlay0 focus:border-blue outline-none"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="rebound" selected>Rebound</option>
|
|
||||||
<option value="slapshot1">Slapshot 1</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-subtext1 mt-1">Select the game version for this season</p>
|
|
||||||
</div>
|
|
||||||
<!-- Start Date Field -->
|
|
||||||
@datepicker.DatePicker("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "dateIsEmpty = $el.value === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();")
|
|
||||||
<p
|
|
||||||
class="text-center text-xs text-red mt-2"
|
|
||||||
x-show="dateError && !isSubmitting"
|
|
||||||
x-cloak
|
|
||||||
x-text="dateError"
|
|
||||||
></p>
|
|
||||||
<!-- General Error Message -->
|
|
||||||
<p
|
|
||||||
class="text-center text-sm text-red"
|
|
||||||
x-show="generalError"
|
|
||||||
x-cloak
|
|
||||||
x-text="generalError"
|
|
||||||
></p>
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<button
|
|
||||||
x-bind:disabled="!canSubmit || isSubmitting"
|
|
||||||
x-text="buttonText"
|
|
||||||
type="submit"
|
|
||||||
class="w-full py-3 px-4 inline-flex justify-center items-center
|
|
||||||
gap-x-2 rounded-lg border border-transparent transition font-semibold
|
|
||||||
bg-blue hover:bg-blue/75 text-mantle hover:cursor-pointer
|
|
||||||
disabled:bg-blue/40 disabled:cursor-not-allowed"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
275
internal/view/seasonsview/new_form_modal.templ
Normal file
275
internal/view/seasonsview/new_form_modal.templ
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
// NewFormModal is a version of the form for use in a modal
|
||||||
|
// It closes the modal and refreshes the list on success instead of redirecting
|
||||||
|
templ NewFormModal() {
|
||||||
|
<form
|
||||||
|
hx-post="/seasons/new"
|
||||||
|
hx-target="#seasons-list-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
x-data={ templ.JSFuncCall("newSeasonFormModalData").CallInline }
|
||||||
|
@submit="handleSubmit()"
|
||||||
|
@htmx:after-request="
|
||||||
|
if(submitTimeout) clearTimeout(submitTimeout);
|
||||||
|
// Only handle the form submission (not uniqueness checks)
|
||||||
|
const path = $event.detail.requestConfig.path;
|
||||||
|
if(path !== '/seasons/new') return;
|
||||||
|
if($event.detail.successful) {
|
||||||
|
// Close the modal
|
||||||
|
$dispatch('close-new-season-modal');
|
||||||
|
// Reset form
|
||||||
|
$el.reset();
|
||||||
|
isSubmitting=false;
|
||||||
|
buttonText='Create Season';
|
||||||
|
// Reset validation state
|
||||||
|
nameIsEmpty=true;
|
||||||
|
nameIsUnique=false;
|
||||||
|
shortNameIsEmpty=true;
|
||||||
|
shortNameIsUnique=false;
|
||||||
|
dateIsEmpty=true;
|
||||||
|
updateCanSubmit();
|
||||||
|
// Clear flatpickr
|
||||||
|
const fpInput = document.getElementById('start_date');
|
||||||
|
if(fpInput && fpInput._flatpickr) {
|
||||||
|
fpInput._flatpickr.clear();
|
||||||
|
}
|
||||||
|
} else if($event.detail.xhr.status !== 409) {
|
||||||
|
isSubmitting=false;
|
||||||
|
buttonText='Create Season';
|
||||||
|
generalError='An error occurred. Please try again.';
|
||||||
|
}
|
||||||
|
"
|
||||||
|
x-init="initFlatpickrModal()"
|
||||||
|
>
|
||||||
|
<script>
|
||||||
|
function newSeasonFormModalData() {
|
||||||
|
return {
|
||||||
|
canSubmit: false,
|
||||||
|
buttonText: "Create Season",
|
||||||
|
// Name validation state
|
||||||
|
nameError: "",
|
||||||
|
nameIsChecking: false,
|
||||||
|
nameIsUnique: false,
|
||||||
|
nameIsEmpty: true,
|
||||||
|
// Short name validation state
|
||||||
|
shortNameError: "",
|
||||||
|
shortNameIsChecking: false,
|
||||||
|
shortNameIsUnique: false,
|
||||||
|
shortNameIsEmpty: true,
|
||||||
|
// Date validation state
|
||||||
|
dateError: "",
|
||||||
|
dateIsEmpty: true,
|
||||||
|
// Form state
|
||||||
|
isSubmitting: false,
|
||||||
|
generalError: "",
|
||||||
|
submitTimeout: null,
|
||||||
|
// Reset name errors
|
||||||
|
resetNameErr() {
|
||||||
|
this.nameError = "";
|
||||||
|
this.nameIsChecking = false;
|
||||||
|
this.nameIsUnique = false;
|
||||||
|
},
|
||||||
|
// Reset short name errors
|
||||||
|
resetShortNameErr() {
|
||||||
|
this.shortNameError = "";
|
||||||
|
this.shortNameIsChecking = false;
|
||||||
|
this.shortNameIsUnique = false;
|
||||||
|
},
|
||||||
|
// Reset date errors
|
||||||
|
resetDateErr() {
|
||||||
|
this.dateError = "";
|
||||||
|
},
|
||||||
|
// Check if form can be submitted
|
||||||
|
updateCanSubmit() {
|
||||||
|
this.canSubmit =
|
||||||
|
!this.nameIsEmpty &&
|
||||||
|
this.nameIsUnique &&
|
||||||
|
!this.nameIsChecking &&
|
||||||
|
!this.shortNameIsEmpty &&
|
||||||
|
this.shortNameIsUnique &&
|
||||||
|
!this.shortNameIsChecking &&
|
||||||
|
!this.dateIsEmpty;
|
||||||
|
},
|
||||||
|
// Handle form submission
|
||||||
|
handleSubmit() {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
this.buttonText = "Creating...";
|
||||||
|
this.generalError = "";
|
||||||
|
// Set timeout for 10 seconds
|
||||||
|
this.submitTimeout = setTimeout(() => {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
this.buttonText = "Create Season";
|
||||||
|
this.generalError = "Request timed out. Please try again.";
|
||||||
|
}, 10000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function initFlatpickrModal() {
|
||||||
|
// Small delay to ensure DOM is ready after modal opens
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof flatpickr !== 'undefined') {
|
||||||
|
flatpickr('#start_date', {
|
||||||
|
dateFormat: 'd/m/Y',
|
||||||
|
allowInput: true,
|
||||||
|
onChange: function(selectedDates, dateStr) {
|
||||||
|
const input = document.getElementById('start_date');
|
||||||
|
input.value = dateStr;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<div class="grid gap-y-5">
|
||||||
|
<!-- Name and Short Name Fields (Inlined) -->
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<!-- Name Field -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label for="name" class="block text-sm font-medium mb-2 text-subtext0">Season Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
maxlength="20"
|
||||||
|
x-bind:class="{
|
||||||
|
'py-3 px-4 block w-full rounded-lg text-sm bg-surface0 disabled:opacity-50 disabled:pointer-events-none border-2 outline-none text-text placeholder-subtext0': true,
|
||||||
|
'border-surface1 focus:border-blue': !nameIsUnique && !nameError,
|
||||||
|
'border-green focus:border-green': nameIsUnique && !nameIsChecking && !nameError,
|
||||||
|
'border-red focus:border-red': nameError && !nameIsChecking && !isSubmitting
|
||||||
|
}"
|
||||||
|
required
|
||||||
|
placeholder="e.g. Season 1"
|
||||||
|
@input="resetNameErr(); nameIsEmpty = $el.value.trim() === ''; if(nameIsEmpty) { nameError='Season name is required'; nameIsUnique=false; } updateCanSubmit();"
|
||||||
|
hx-post="/htmx/isseasonnameunique"
|
||||||
|
hx-trigger="input changed delay:500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
@htmx:before-request="if($el.value.trim() === '') { nameIsEmpty=true; return; } nameIsEmpty=false; nameIsChecking=true; nameIsUnique=false; nameError=''; updateCanSubmit();"
|
||||||
|
@htmx:after-request="nameIsChecking=false; if($event.detail.successful) { nameIsUnique=true; } else if($event.detail.xhr.status === 409) { nameError='This season name is already taken'; nameIsUnique=false; } updateCanSubmit();"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-center text-xs text-red mt-2"
|
||||||
|
x-show="nameError && !isSubmitting"
|
||||||
|
x-cloak
|
||||||
|
x-text="nameError"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
<!-- Short Name Field -->
|
||||||
|
<div class="col-span-1">
|
||||||
|
<label for="short_name" class="block text-sm font-medium mb-2 text-subtext0">Short Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="short_name"
|
||||||
|
name="short_name"
|
||||||
|
maxlength="6"
|
||||||
|
x-bind:class="{
|
||||||
|
'py-3 px-4 block w-full rounded-lg text-sm bg-surface0 disabled:opacity-50 disabled:pointer-events-none border-2 outline-none uppercase text-text placeholder-subtext0': true,
|
||||||
|
'border-surface1 focus:border-blue': !shortNameIsUnique && !shortNameError,
|
||||||
|
'border-green focus:border-green': shortNameIsUnique && !shortNameIsChecking && !shortNameError,
|
||||||
|
'border-red focus:border-red': shortNameError && !shortNameIsChecking && !isSubmitting
|
||||||
|
}"
|
||||||
|
required
|
||||||
|
placeholder="e.g. S1"
|
||||||
|
pattern="[A-Z0-9]+"
|
||||||
|
@input="
|
||||||
|
let val = $el.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
$el.value = val;
|
||||||
|
resetShortNameErr();
|
||||||
|
shortNameIsEmpty = val.trim() === '';
|
||||||
|
if(shortNameIsEmpty) {
|
||||||
|
shortNameError='Short name is required';
|
||||||
|
shortNameIsUnique=false;
|
||||||
|
}
|
||||||
|
updateCanSubmit();
|
||||||
|
"
|
||||||
|
hx-post="/htmx/isseasonshortnameunique"
|
||||||
|
hx-trigger="input changed delay:500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
@htmx:before-request="if($el.value.trim() === '') { shortNameIsEmpty=true; return; } shortNameIsEmpty=false; shortNameIsChecking=true; shortNameIsUnique=false; shortNameError=''; updateCanSubmit();"
|
||||||
|
@htmx:after-request="shortNameIsChecking=false; if($event.detail.successful) { shortNameIsUnique=true; } else if($event.detail.xhr.status === 409) { shortNameError='This short name is already taken'; shortNameIsUnique=false; } updateCanSubmit();"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-center text-xs text-red mt-2"
|
||||||
|
x-show="shortNameError && !isSubmitting"
|
||||||
|
x-cloak
|
||||||
|
x-text="shortNameError"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-subtext0 -mt-3">Maximum 20 characters for name, 6 alphanumeric characters for short name</p>
|
||||||
|
<!-- Season Type and Start Date Fields (Inlined) -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 items-end">
|
||||||
|
<!-- Season Type Field -->
|
||||||
|
<div>
|
||||||
|
<label for="type" class="block text-sm font-medium mb-2 text-subtext0">Season Type</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
name="type"
|
||||||
|
class="py-3 px-4 block w-full rounded-lg text-sm bg-surface0 border-2 border-surface1 focus:border-blue outline-none text-text appearance-none cursor-pointer hover:bg-surface1 transition-colors"
|
||||||
|
required
|
||||||
|
style="height: 46px;"
|
||||||
|
>
|
||||||
|
<option value="regular" selected>Regular</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Start Date Field (Flatpickr) -->
|
||||||
|
<div>
|
||||||
|
<label for="start_date" class="block text-sm font-medium mb-2 text-subtext0">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="start_date"
|
||||||
|
name="start_date"
|
||||||
|
class="py-3 px-4 block w-full rounded-lg text-sm bg-surface0 border-2 border-surface1 focus:border-blue outline-none text-text placeholder-subtext0"
|
||||||
|
placeholder="DD/MM/YYYY"
|
||||||
|
required
|
||||||
|
style="height: 46px;"
|
||||||
|
@input="dateIsEmpty = $el.value.trim() === ''; resetDateErr(); if(dateIsEmpty) { dateError='Start date is required'; } updateCanSubmit();"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-center text-xs text-red -mt-3"
|
||||||
|
x-show="dateError && !isSubmitting"
|
||||||
|
x-cloak
|
||||||
|
x-text="dateError"
|
||||||
|
></p>
|
||||||
|
<!-- Slap Version Field -->
|
||||||
|
<div>
|
||||||
|
<label for="slap_version" class="block text-sm font-medium mb-2 text-subtext0">Slap Version</label>
|
||||||
|
<select
|
||||||
|
id="slap_version"
|
||||||
|
name="slap_version"
|
||||||
|
class="py-3 px-4 block w-full rounded-lg text-sm bg-surface0 border-2 border-surface1 focus:border-blue outline-none text-text appearance-none cursor-pointer hover:bg-surface1 transition-colors"
|
||||||
|
required
|
||||||
|
style="height: 46px;"
|
||||||
|
>
|
||||||
|
<option value="rebound" selected>Rebound</option>
|
||||||
|
<option value="slapshot1">Slapshot 1</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-subtext0 mt-1">Select the game game version for this season</p>
|
||||||
|
</div>
|
||||||
|
<!-- General Error Message -->
|
||||||
|
<p
|
||||||
|
class="text-center text-sm text-red"
|
||||||
|
x-show="generalError"
|
||||||
|
x-cloak
|
||||||
|
x-text="generalError"
|
||||||
|
></p>
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
x-bind:disabled="!canSubmit || isSubmitting"
|
||||||
|
x-text="buttonText"
|
||||||
|
type="submit"
|
||||||
|
class="w-full py-3 px-4 inline-flex justify-center items-center
|
||||||
|
gap-x-2 rounded-lg border border-transparent transition-all duration-200 font-semibold
|
||||||
|
bg-blue hover:bg-blue/80 text-mantle hover:cursor-pointer shadow-md
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none hover:shadow-lg hover:-translate-y-0.5"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package seasonsview
|
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
|
||||||
|
|
||||||
templ NewPage() {
|
|
||||||
@baseview.Layout("New Season") {
|
|
||||||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
|
||||||
<div class="bg-mantle border border-surface1 rounded-xl">
|
|
||||||
<div class="p-6 sm:p-8">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-text">Create New Season</h1>
|
|
||||||
<p class="mt-2 text-sm text-subtext0">
|
|
||||||
Add a new season to the system. All fields are required.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-md mx-auto">
|
|
||||||
@NewForm()
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
65
internal/view/seasonsview/new_season_modal.templ
Normal file
65
internal/view/seasonsview/new_season_modal.templ
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
// NewSeasonModal renders a modal containing the new season form
|
||||||
|
// This is used on the seasons list page for a better UX
|
||||||
|
templ NewSeasonModal() {
|
||||||
|
<div
|
||||||
|
x-data="{ open: false }"
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
@open-new-season-modal.window="open = true"
|
||||||
|
@close-new-season-modal.window="open = false"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<!-- Background overlay -->
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition:enter="ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 bg-base/75 transition-opacity"
|
||||||
|
@click="open = false"
|
||||||
|
></div>
|
||||||
|
<!-- Modal panel -->
|
||||||
|
<div class="flex min-h-full items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition:enter="ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave="ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
class="relative transform overflow-hidden rounded-xl bg-mantle border border-surface0 shadow-xl transition-all w-full max-w-lg"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="px-6 py-4 border-b border-surface0">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-xl font-bold text-text" id="modal-title">Create New Season</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="open = false"
|
||||||
|
class="text-subtext0 hover:text-text transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-subtext0 mt-1">Add a new season to the system. All fields are required.</p>
|
||||||
|
</div>
|
||||||
|
<!-- Form content -->
|
||||||
|
<div class="p-6">
|
||||||
|
@NewFormModal()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -4,14 +4,16 @@ import "git.haelnorr.com/h/oslstats/internal/db"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
import "time"
|
||||||
|
|
||||||
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture) {
|
templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||||
@SeasonLeagueLayout("fixtures", season, league) {
|
@SeasonLeagueLayout("fixtures", season, league) {
|
||||||
@SeasonLeagueFixtures(season, league, fixtures)
|
@SeasonLeagueFixtures(season, league, fixtures, scheduleMap, resultMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) {
|
templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
permCache := contexts.Permissions(ctx)
|
||||||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||||||
@@ -35,6 +37,23 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
|||||||
}
|
}
|
||||||
groups[idx].Fixtures = append(groups[idx].Fixtures, f)
|
groups[idx].Fixtures = append(groups[idx].Fixtures, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort fixtures within each group by scheduled time
|
||||||
|
// Scheduled fixtures first (by time), then TBD last
|
||||||
|
farFuture := time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := range groups {
|
||||||
|
sort.Slice(groups[i].Fixtures, func(a, b int) bool {
|
||||||
|
ta := farFuture
|
||||||
|
tb := farFuture
|
||||||
|
if sa, ok := scheduleMap[groups[i].Fixtures[a].ID]; ok && sa.ScheduledTime != nil {
|
||||||
|
ta = *sa.ScheduledTime
|
||||||
|
}
|
||||||
|
if sb, ok := scheduleMap[groups[i].Fixtures[b].ID]; ok && sb.ScheduledTime != nil {
|
||||||
|
tb = *sb.ScheduledTime
|
||||||
|
}
|
||||||
|
return ta.Before(tb)
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
<div>
|
<div>
|
||||||
if canManage {
|
if canManage {
|
||||||
@@ -55,13 +74,78 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
|||||||
} else {
|
} else {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
{{
|
||||||
<div class="bg-mantle border-b border-surface1 px-4 py-3">
|
playedCount := 0
|
||||||
|
for _, f := range group.Fixtures {
|
||||||
|
if res, ok := resultMap[f.ID]; ok && res.Finalized {
|
||||||
|
playedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasPlayed := playedCount > 0
|
||||||
|
allPlayed := playedCount == len(group.Fixtures)
|
||||||
|
}}
|
||||||
|
<div
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg overflow-hidden"
|
||||||
|
x-data="{ showPlayed: false }"
|
||||||
|
>
|
||||||
|
<div class="bg-mantle border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
|
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
|
||||||
|
if hasPlayed {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPlayed = !showPlayed"
|
||||||
|
class="text-xs px-2.5 py-1 rounded-lg transition cursor-pointer
|
||||||
|
bg-surface1 hover:bg-surface2 text-subtext0 hover:text-text"
|
||||||
|
>
|
||||||
|
<span x-show="!showPlayed">Show played</span>
|
||||||
|
<span x-show="showPlayed" x-cloak>Hide played</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-surface1">
|
<div class="divide-y divide-surface1">
|
||||||
for _, fixture := range group.Fixtures {
|
for _, fixture := range group.Fixtures {
|
||||||
<div class="px-4 py-3 flex items-center justify-between">
|
{{
|
||||||
|
sched, hasSchedule := scheduleMap[fixture.ID]
|
||||||
|
_ = sched
|
||||||
|
res, hasResult := resultMap[fixture.ID]
|
||||||
|
_ = res
|
||||||
|
isPlayed := hasResult && res.Finalized
|
||||||
|
}}
|
||||||
|
if isPlayed {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||||
|
x-show="showPlayed"
|
||||||
|
x-cloak
|
||||||
|
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||||
|
>
|
||||||
|
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||||
|
class="px-4 py-3 flex items-center justify-between hover:bg-surface1 transition hover:cursor-pointer block"
|
||||||
|
>
|
||||||
|
@fixtureListItem(fixture, sched, hasSchedule, res, hasResult)
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if allPlayed {
|
||||||
|
<div
|
||||||
|
x-show="!showPlayed"
|
||||||
|
class="px-4 py-3 text-center text-xs text-subtext1 italic"
|
||||||
|
>
|
||||||
|
All fixtures played
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ fixtureListItem(fixture *db.Fixture, sched *db.FixtureSchedule, hasSchedule bool, res *db.FixtureResult, hasResult bool) {
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
|
||||||
R{ fmt.Sprint(fixture.Round) }
|
R{ fmt.Sprint(fixture.Round) }
|
||||||
@@ -74,12 +158,29 @@ templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.
|
|||||||
{ fixture.AwayTeam.Name }
|
{ fixture.AwayTeam.Name }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
if hasResult {
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
if res.Winner == "home" {
|
||||||
|
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||||
|
<span class="text-xs text-subtext0">–</span>
|
||||||
|
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
||||||
|
} else if res.Winner == "away" {
|
||||||
|
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
||||||
|
<span class="text-xs text-subtext0">–</span>
|
||||||
|
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||||
|
<span class="text-xs text-subtext0">–</span>
|
||||||
|
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||||
}
|
}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
} else if hasSchedule && sched.ScheduledTime != nil {
|
||||||
|
<span class="text-xs text-green font-medium">
|
||||||
|
@localtime(sched.ScheduledTime, "short")
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs text-subtext1">
|
||||||
|
TBD
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,18 +60,19 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<!-- Generate -->
|
<!-- Generate -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<label class="text-sm text-subtext0">Round:</label>
|
||||||
type="number"
|
<select
|
||||||
x-model.number="generateRounds"
|
x-model.number="generateRounds"
|
||||||
min="1"
|
class="py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
|
||||||
max="20"
|
|
||||||
placeholder="Rounds"
|
|
||||||
class="w-24 py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
|
|
||||||
focus:border-blue outline-none text-text"
|
focus:border-blue outline-none text-text"
|
||||||
/>
|
>
|
||||||
|
<template x-for="r in availableRounds" :key="r">
|
||||||
|
<option :value="r" x-text="r"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
@click="generate()"
|
@click="generate()"
|
||||||
:disabled="isGenerating || generateRounds < 1"
|
:disabled="isGenerating || availableRounds.length === 0"
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
bg-blue hover:bg-blue/80 text-mantle transition
|
bg-blue hover:bg-blue/80 text-mantle transition
|
||||||
disabled:bg-blue/40 disabled:cursor-not-allowed"
|
disabled:bg-blue/40 disabled:cursor-not-allowed"
|
||||||
@@ -79,32 +80,40 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
<span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span>
|
<span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Clear All -->
|
<!-- Delete All -->
|
||||||
<button
|
<button
|
||||||
x-show="allFixtures.length > 0"
|
x-show="allFixtures.length > 0"
|
||||||
@click="clearAll()"
|
@click="deleteAll()"
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
bg-red hover:bg-red/80 text-mantle transition"
|
bg-red hover:bg-red/80 text-mantle transition"
|
||||||
>
|
>
|
||||||
Clear All
|
Delete All
|
||||||
|
</button>
|
||||||
|
<!-- Save / Reset -->
|
||||||
|
<div x-show="unsavedChanges" class="flex items-center gap-2 ml-auto">
|
||||||
|
<span
|
||||||
|
x-show="!canSave()"
|
||||||
|
class="text-yellow text-xs"
|
||||||
|
>
|
||||||
|
All game weeks must have at least 1 fixture
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="reset()"
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
</button>
|
</button>
|
||||||
<!-- Save -->
|
|
||||||
<button
|
<button
|
||||||
x-show="unsavedChanges"
|
|
||||||
@click="save()"
|
@click="save()"
|
||||||
:disabled="isSaving || !canSave()"
|
:disabled="isSaving || !canSave()"
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
bg-green hover:bg-green/75 text-mantle transition
|
bg-green hover:bg-green/75 text-mantle transition
|
||||||
disabled:bg-green/40 disabled:cursor-not-allowed ml-auto"
|
disabled:bg-green/40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span x-text="isSaving ? 'Saving...' : 'Save'"></span>
|
<span x-text="isSaving ? 'Saving...' : 'Save'"></span>
|
||||||
</button>
|
</button>
|
||||||
<span
|
</div>
|
||||||
x-show="unsavedChanges && !canSave()"
|
|
||||||
class="text-yellow text-xs ml-2"
|
|
||||||
>
|
|
||||||
All game weeks must have at least 1 fixture
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Main content panels -->
|
<!-- Main content panels -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
@@ -114,7 +123,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
<div x-show="selectedGameWeek === null">
|
<div x-show="selectedGameWeek === null">
|
||||||
<h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3>
|
<h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3>
|
||||||
<div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4">
|
<div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4">
|
||||||
No game weeks yet. Add one to start allocating fixtures.
|
No game weeks yet. Generate fixtures to get started.
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<template x-for="week in allGameWeekNumbers" :key="week">
|
<template x-for="week in allGameWeekNumbers" :key="week">
|
||||||
@@ -271,9 +280,12 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
</div>
|
</div>
|
||||||
<!-- Alpine.js component -->
|
<!-- Alpine.js component -->
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data('fixturesManager', (initialFixtures, seasonShortName, leagueShortName) => ({
|
Alpine.data(
|
||||||
|
"fixturesManager",
|
||||||
|
(initialFixtures, seasonShortName, leagueShortName) => ({
|
||||||
allFixtures: initialFixtures || [],
|
allFixtures: initialFixtures || [],
|
||||||
|
_initialFixtures: JSON.parse(JSON.stringify(initialFixtures || [])),
|
||||||
seasonShortName: seasonShortName,
|
seasonShortName: seasonShortName,
|
||||||
leagueShortName: leagueShortName,
|
leagueShortName: leagueShortName,
|
||||||
|
|
||||||
@@ -282,7 +294,12 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
unsavedChanges: false,
|
unsavedChanges: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
generateRounds: 1,
|
generateRounds: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.generateRounds =
|
||||||
|
this.availableRounds.length > 0 ? this.availableRounds[0] : 1;
|
||||||
|
},
|
||||||
|
|
||||||
// Drag state
|
// Drag state
|
||||||
draggedFixture: null,
|
draggedFixture: null,
|
||||||
@@ -291,7 +308,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
// Computed
|
// Computed
|
||||||
get unallocatedFixtures() {
|
get unallocatedFixtures() {
|
||||||
return this.allFixtures
|
return this.allFixtures
|
||||||
.filter(f => f.gameWeek === null)
|
.filter((f) => f.gameWeek === null)
|
||||||
.sort((a, b) => a.round - b.round || a.id - b.id);
|
.sort((a, b) => a.round - b.round || a.id - b.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -309,17 +326,41 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
return [...weeks].sort((a, b) => a - b);
|
return [...weeks].sort((a, b) => a - b);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get existingRounds() {
|
||||||
|
const rounds = new Set();
|
||||||
|
for (const f of this.allFixtures) {
|
||||||
|
rounds.add(f.round);
|
||||||
|
}
|
||||||
|
return rounds;
|
||||||
|
},
|
||||||
|
|
||||||
|
get availableRounds() {
|
||||||
|
const taken = this.existingRounds;
|
||||||
|
const maxTaken = taken.size > 0 ? Math.max(...taken) : 0;
|
||||||
|
const limit = maxTaken + 5;
|
||||||
|
const available = [];
|
||||||
|
for (let i = 1; i <= limit; i++) {
|
||||||
|
if (!taken.has(i)) available.push(i);
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
},
|
||||||
|
|
||||||
// Track empty weeks that user created
|
// Track empty weeks that user created
|
||||||
_emptyWeeks: [],
|
// Default to [1] if no fixtures have game weeks assigned
|
||||||
|
_emptyWeeks: (initialFixtures || []).some(
|
||||||
|
(f) => f.gameWeek !== null,
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: [1],
|
||||||
|
|
||||||
getGameWeekFixtures(week) {
|
getGameWeekFixtures(week) {
|
||||||
return this.allFixtures
|
return this.allFixtures
|
||||||
.filter(f => f.gameWeek === week)
|
.filter((f) => f.gameWeek === week)
|
||||||
.sort((a, b) => a.round - b.round || a.id - b.id);
|
.sort((a, b) => a.round - b.round || a.id - b.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
getFixtureCount(week) {
|
getFixtureCount(week) {
|
||||||
return this.allFixtures.filter(f => f.gameWeek === week).length;
|
return this.allFixtures.filter((f) => f.gameWeek === week).length;
|
||||||
},
|
},
|
||||||
|
|
||||||
getPreview(week) {
|
getPreview(week) {
|
||||||
@@ -336,7 +377,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
|
|
||||||
deleteGameWeek(week) {
|
deleteGameWeek(week) {
|
||||||
if (this.getFixtureCount(week) > 0) return;
|
if (this.getFixtureCount(week) > 0) return;
|
||||||
this._emptyWeeks = this._emptyWeeks.filter(w => w !== week);
|
this._emptyWeeks = this._emptyWeeks.filter((w) => w !== week);
|
||||||
if (this.selectedGameWeek === week) {
|
if (this.selectedGameWeek === week) {
|
||||||
this.selectedGameWeek = null;
|
this.selectedGameWeek = null;
|
||||||
}
|
}
|
||||||
@@ -354,8 +395,8 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
// Drag and drop
|
// Drag and drop
|
||||||
onDragStart(event, fixture) {
|
onDragStart(event, fixture) {
|
||||||
this.draggedFixture = fixture;
|
this.draggedFixture = fixture;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = "move";
|
||||||
event.dataTransfer.setData('text/plain', fixture.id);
|
event.dataTransfer.setData("text/plain", fixture.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
onDragEnd() {
|
onDragEnd() {
|
||||||
@@ -366,15 +407,17 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
onDrop(target) {
|
onDrop(target) {
|
||||||
if (!this.draggedFixture) return;
|
if (!this.draggedFixture) return;
|
||||||
|
|
||||||
const fixture = this.allFixtures.find(f => f.id === this.draggedFixture.id);
|
const fixture = this.allFixtures.find(
|
||||||
|
(f) => f.id === this.draggedFixture.id,
|
||||||
|
);
|
||||||
if (!fixture) return;
|
if (!fixture) return;
|
||||||
|
|
||||||
if (target === 'unallocated') {
|
if (target === "unallocated") {
|
||||||
fixture.gameWeek = null;
|
fixture.gameWeek = null;
|
||||||
} else {
|
} else {
|
||||||
fixture.gameWeek = target;
|
fixture.gameWeek = target;
|
||||||
// Remove from empty weeks if it now has fixtures
|
// Remove from empty weeks if it now has fixtures
|
||||||
this._emptyWeeks = this._emptyWeeks.filter(w => w !== target);
|
this._emptyWeeks = this._emptyWeeks.filter((w) => w !== target);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.unsavedChanges = true;
|
this.unsavedChanges = true;
|
||||||
@@ -383,7 +426,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
},
|
},
|
||||||
|
|
||||||
unallocateFixture(fixture) {
|
unallocateFixture(fixture) {
|
||||||
const f = this.allFixtures.find(ff => ff.id === fixture.id);
|
const f = this.allFixtures.find((ff) => ff.id === fixture.id);
|
||||||
if (f) {
|
if (f) {
|
||||||
const oldWeek = f.gameWeek;
|
const oldWeek = f.gameWeek;
|
||||||
f.gameWeek = null;
|
f.gameWeek = null;
|
||||||
@@ -405,21 +448,36 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.allFixtures = JSON.parse(
|
||||||
|
JSON.stringify(this._initialFixtures),
|
||||||
|
);
|
||||||
|
this._emptyWeeks = this._initialFixtures.some(
|
||||||
|
(f) => f.gameWeek !== null,
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: [1];
|
||||||
|
this.selectedGameWeek = null;
|
||||||
|
this.unsavedChanges = false;
|
||||||
|
},
|
||||||
|
|
||||||
// Server actions
|
// Server actions
|
||||||
generate() {
|
generate() {
|
||||||
if (this.generateRounds < 1) return;
|
if (this.generateRounds < 1) return;
|
||||||
this.isGenerating = true;
|
this.isGenerating = true;
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('season_short_name', this.seasonShortName);
|
form.append("season_short_name", this.seasonShortName);
|
||||||
form.append('league_short_name', this.leagueShortName);
|
form.append("league_short_name", this.leagueShortName);
|
||||||
form.append('round', this.generateRounds);
|
form.append("round", this.generateRounds);
|
||||||
|
|
||||||
htmx.ajax('POST', '/fixtures/generate', {
|
htmx
|
||||||
target: '#manage-fixtures-content',
|
.ajax("POST", "/fixtures/generate", {
|
||||||
swap: 'outerHTML',
|
target: "#manage-fixtures-content",
|
||||||
values: Object.fromEntries(form)
|
swap: "outerHTML",
|
||||||
}).finally(() => {
|
values: Object.fromEntries(form),
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
this.isGenerating = false;
|
this.isGenerating = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -428,49 +486,66 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
|
|||||||
if (!this.canSave()) return;
|
if (!this.canSave()) return;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
const form = new FormData();
|
const params = new URLSearchParams();
|
||||||
form.append('season_short_name', this.seasonShortName);
|
params.append("season_short_name", this.seasonShortName);
|
||||||
form.append('league_short_name', this.leagueShortName);
|
params.append("league_short_name", this.leagueShortName);
|
||||||
|
|
||||||
this.allFixtures.forEach((f, i) => {
|
this.allFixtures.forEach((f, i) => {
|
||||||
form.append('allocations[' + i + '][id]', f.id);
|
params.append("allocations[" + i + "][id]", f.id);
|
||||||
form.append('allocations[' + i + '][game_week]', f.gameWeek !== null ? f.gameWeek : 0);
|
params.append(
|
||||||
|
"allocations[" + i + "][game_week]",
|
||||||
|
f.gameWeek !== null ? f.gameWeek : 0,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch('/fixtures/update-game-weeks', {
|
fetch("/fixtures/update-game-weeks", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: form
|
headers: {
|
||||||
}).then(response => {
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.unsavedChanges = false;
|
this.unsavedChanges = false;
|
||||||
}
|
}
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
}).catch(() => {
|
})
|
||||||
|
.catch(() => {
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll() {
|
deleteAll() {
|
||||||
const seasonShort = this.seasonShortName;
|
const seasonShort = this.seasonShortName;
|
||||||
const leagueShort = this.leagueShortName;
|
const leagueShort = this.leagueShortName;
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('confirm-action', {
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("confirm-action", {
|
||||||
detail: {
|
detail: {
|
||||||
title: 'Clear All Fixtures',
|
title: "Delete All Fixtures",
|
||||||
message: 'This will delete all fixtures for this league. This action cannot be undone.',
|
message:
|
||||||
|
"This will delete all fixtures for this league. This action cannot be undone.",
|
||||||
action: () => {
|
action: () => {
|
||||||
htmx.ajax('DELETE',
|
htmx.ajax(
|
||||||
'/seasons/' + seasonShort + '/leagues/' + leagueShort + '/fixtures',
|
"DELETE",
|
||||||
|
"/seasons/" +
|
||||||
|
seasonShort +
|
||||||
|
"/leagues/" +
|
||||||
|
leagueShort +
|
||||||
|
"/fixtures",
|
||||||
{
|
{
|
||||||
target: '#manage-fixtures-content',
|
target: "#manage-fixtures-content",
|
||||||
swap: 'outerHTML'
|
swap: "outerHTML",
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
}));
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
168
internal/view/seasonsview/season_league_free_agents.templ
Normal file
168
internal/view/seasonsview/season_league_free_agents.templ
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
|
||||||
|
@SeasonLeagueLayout("free-agents", season, league) {
|
||||||
|
@SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canAdd := permCache.HasPermission(permissions.FreeAgentsAdd)
|
||||||
|
canRemove := permCache.HasPermission(permissions.FreeAgentsRemove)
|
||||||
|
}}
|
||||||
|
<div x-data="{ showAddModal: false, selectedPlayerId: '' }">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Free Agents ({ fmt.Sprint(len(freeAgents)) })</h2>
|
||||||
|
if canAdd {
|
||||||
|
<button
|
||||||
|
@click="showAddModal = true"
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
|
bg-green hover:bg-green/75 text-mantle transition"
|
||||||
|
>
|
||||||
|
Add Free Agent
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if len(freeAgents) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No free agents registered in this league yet.</p>
|
||||||
|
if canAdd {
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Click "Add Free Agent" to register a player.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Registered By</th>
|
||||||
|
if canRemove {
|
||||||
|
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, fa := range freeAgents {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-sm text-text">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{ fa.Player.DisplayName() }
|
||||||
|
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
|
||||||
|
FREE AGENT
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-subtext0">
|
||||||
|
if fa.RegisteredBy != nil {
|
||||||
|
{ fa.RegisteredBy.Username }
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
if canRemove {
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/free-agents/unregister", season.ShortName, league.ShortName) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="player_id" value={ fmt.Sprint(fa.PlayerID) }/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-3 py-1 text-xs bg-red/20 hover:bg-red/40 text-red rounded
|
||||||
|
transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if canAdd {
|
||||||
|
@addFreeAgentModal(season, league, availablePlayers)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ addFreeAgentModal(season *db.Season, league *db.League, availablePlayers []*db.Player) {
|
||||||
|
<div
|
||||||
|
x-show="showAddModal"
|
||||||
|
@keydown.escape.window="showAddModal = false"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-crust/80 transition-opacity"
|
||||||
|
@click="showAddModal = false"
|
||||||
|
></div>
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="flex min-h-full items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-md w-full p-6"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<h3 class="text-2xl font-bold text-text mb-4">Add Free Agent</h3>
|
||||||
|
<form
|
||||||
|
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/free-agents/register", season.ShortName, league.ShortName) }
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
if len(availablePlayers) == 0 {
|
||||||
|
<p class="text-subtext0 mb-4">No players available to register as free agents. All players are either on a team or already registered.</p>
|
||||||
|
} else {
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="player_id" class="block text-sm font-medium mb-2">Select Player</label>
|
||||||
|
<select
|
||||||
|
id="player_id"
|
||||||
|
name="player_id"
|
||||||
|
x-model="selectedPlayerId"
|
||||||
|
required
|
||||||
|
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
|
||||||
|
focus:border-blue outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Choose a player...</option>
|
||||||
|
for _, player := range availablePlayers {
|
||||||
|
<option value={ fmt.Sprint(player.ID) }>
|
||||||
|
{ player.DisplayName() }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showAddModal = false"
|
||||||
|
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
if len(availablePlayers) > 0 {
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!selectedPlayerId"
|
||||||
|
class="px-4 py-2 rounded-lg bg-green hover:bg-green/75 text-mantle transition
|
||||||
|
disabled:bg-green/40 disabled:cursor-not-allowed hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Register Free Agent
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -3,8 +3,15 @@ package seasonsview
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
|
||||||
templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.League) {
|
templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.League) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canEditSeason := permCache.HasPermission(permissions.SeasonsUpdate)
|
||||||
|
isDraftSeason := season.Type == db.SeasonTypeDraft.String()
|
||||||
|
}}
|
||||||
@baseview.Layout(fmt.Sprintf("%s - %s", season.Name, league.Name)) {
|
@baseview.Layout(fmt.Sprintf("%s - %s", season.Name, league.Name)) {
|
||||||
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
@@ -12,19 +19,39 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
|
|||||||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name } - { league.Name }</h1>
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
if isDraftSeason {
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ season.Name }</h1>
|
||||||
|
} else {
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ season.Name } - { league.Name }</h1>
|
||||||
|
}
|
||||||
|
<span class="text-lg font-mono text-subtext0 bg-surface1 px-2 py-0.5 rounded">{ season.ShortName }</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="inline-block bg-surface1 px-3 py-1 rounded text-sm text-subtext0 font-mono">
|
@SeasonTypeBadge(season.Type)
|
||||||
{ season.ShortName }
|
|
||||||
</span>
|
|
||||||
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
|
|
||||||
{ league.ShortName }
|
|
||||||
</span>
|
|
||||||
@SlapVersionBadge(season.SlapVersion)
|
@SlapVersionBadge(season.SlapVersion)
|
||||||
@StatusBadge(season, false, false)
|
@StatusBadge(season, false, false)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
if isDraftSeason {
|
||||||
|
if canEditSeason {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL("/seasons/" + season.ShortName + "/edit") }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-blue hover:bg-blue/75 text-mantle transition"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<a
|
||||||
|
href="/seasons"
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Seasons
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
<a
|
<a
|
||||||
href={ templ.SafeURL("/seasons/" + season.ShortName) }
|
href={ templ.SafeURL("/seasons/" + season.ShortName) }
|
||||||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
@@ -32,6 +59,7 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
|
|||||||
>
|
>
|
||||||
Back to Season
|
Back to Season
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Season Dates -->
|
<!-- Season Dates -->
|
||||||
@@ -92,6 +120,7 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
|
|||||||
@leagueNavItem("table", "Table", activeSection, season, league)
|
@leagueNavItem("table", "Table", activeSection, season, league)
|
||||||
@leagueNavItem("fixtures", "Fixtures", activeSection, season, league)
|
@leagueNavItem("fixtures", "Fixtures", activeSection, season, league)
|
||||||
@leagueNavItem("teams", "Teams", activeSection, season, league)
|
@leagueNavItem("teams", "Teams", activeSection, season, league)
|
||||||
|
@leagueNavItem("free-agents", "Free Agents", activeSection, season, league)
|
||||||
@leagueNavItem("stats", "Stats", activeSection, season, league)
|
@leagueNavItem("stats", "Stats", activeSection, season, league)
|
||||||
@leagueNavItem("finals", "Finals", activeSection, season, league)
|
@leagueNavItem("finals", "Finals", activeSection, season, league)
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,15 +1,115 @@
|
|||||||
package seasonsview
|
package seasonsview
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueTablePage(season *db.Season, league *db.League) {
|
templ SeasonLeagueTablePage(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) {
|
||||||
@SeasonLeagueLayout("table", season, league) {
|
@SeasonLeagueLayout("table", season, league) {
|
||||||
@SeasonLeagueTable()
|
@SeasonLeagueTable(leaderboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueTable() {
|
templ SeasonLeagueTable(leaderboard []*db.LeaderboardEntry) {
|
||||||
|
if len(leaderboard) == 0 {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
<p class="text-subtext0 text-lg">Coming Soon...</p>
|
<p class="text-subtext0 text-lg">No teams in this league yet.</p>
|
||||||
</div>
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<!-- Scoring key -->
|
||||||
|
<div class="bg-mantle border-b border-surface1 px-4 py-2 flex items-center gap-4 text-xs text-subtext0">
|
||||||
|
<span class="font-semibold text-subtext1">Points:</span>
|
||||||
|
<span>W = { fmt.Sprint(db.PointsWin) }</span>
|
||||||
|
<span>OTW = { fmt.Sprint(db.PointsOvertimeWin) }</span>
|
||||||
|
<span>OTL = { fmt.Sprint(db.PointsOvertimeLoss) }</span>
|
||||||
|
<span>L = { fmt.Sprint(db.PointsLoss) }</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-subtext0 w-10">#</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-text">Team</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Wins">W</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Wins">OTW</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Overtime Losses">OTL</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Losses">L</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals For">GF</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goals Against">GA</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-text" title="Goal Differential">GD</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-semibold text-blue" title="Points">PTS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, entry := range leaderboard {
|
||||||
|
@leaderboardRow(entry)
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ leaderboardRow(entry *db.LeaderboardEntry) {
|
||||||
|
{{
|
||||||
|
r := entry.Record
|
||||||
|
goalDiff := r.GoalsFor - r.GoalsAgainst
|
||||||
|
var gdStr string
|
||||||
|
if goalDiff > 0 {
|
||||||
|
gdStr = fmt.Sprintf("+%d", goalDiff)
|
||||||
|
} else {
|
||||||
|
gdStr = fmt.Sprint(goalDiff)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-3 py-3 text-center text-sm font-medium text-subtext0">
|
||||||
|
{ fmt.Sprint(entry.Position) }
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
if entry.Team.Color != "" {
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(entry.Team.Color) }
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-medium text-text">{ entry.Team.Name }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-subtext0">
|
||||||
|
{ fmt.Sprint(r.Played) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-green">
|
||||||
|
{ fmt.Sprint(r.Wins) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-teal">
|
||||||
|
{ fmt.Sprint(r.OvertimeWins) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-peach">
|
||||||
|
{ fmt.Sprint(r.OvertimeLosses) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-red">
|
||||||
|
{ fmt.Sprint(r.Losses) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-text">
|
||||||
|
{ fmt.Sprint(r.GoalsFor) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm text-text">
|
||||||
|
{ fmt.Sprint(r.GoalsAgainst) }
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm">
|
||||||
|
if goalDiff > 0 {
|
||||||
|
<span class="text-green">{ gdStr }</span>
|
||||||
|
} else if goalDiff < 0 {
|
||||||
|
<span class="text-red">{ gdStr }</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-subtext0">{ gdStr }</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm font-bold text-blue">
|
||||||
|
{ fmt.Sprint(r.Points) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
641
internal/view/seasonsview/season_league_team_detail.templ
Normal file
641
internal/view/seasonsview/season_league_team_detail.templ
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||||
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
templ SeasonLeagueTeamDetailPage(twr *db.TeamWithRoster, fixtures []*db.Fixture, available []*db.Player, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult, record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
|
||||||
|
{{
|
||||||
|
team := twr.Team
|
||||||
|
season := twr.Season
|
||||||
|
league := twr.League
|
||||||
|
}}
|
||||||
|
@baseview.Layout(fmt.Sprintf("%s - %s - %s", team.Name, league.Name, season.Name)) {
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4 py-8">
|
||||||
|
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
if team.Color != "" {
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-surface1 shrink-0"
|
||||||
|
style={ "background-color: " + templ.SafeCSS(team.Color) }
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold text-text">{ team.Name }</h1>
|
||||||
|
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
{ team.ShortName }
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono text-sm">
|
||||||
|
{ team.AltShortName }
|
||||||
|
</span>
|
||||||
|
<span class="text-subtext1 text-sm">
|
||||||
|
{ season.Name } — { league.Name }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)) }
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||||||
|
bg-surface1 hover:bg-surface2 text-text transition"
|
||||||
|
>
|
||||||
|
Back to Teams
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="bg-crust p-6">
|
||||||
|
<!-- Top row: Roster (left) + Fixtures (right) -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
@TeamRosterSection(twr, available)
|
||||||
|
@teamFixturesPane(twr.Team, fixtures, scheduleMap, resultMap)
|
||||||
|
</div>
|
||||||
|
<!-- Stats below both -->
|
||||||
|
<div class="mt-6">
|
||||||
|
@teamStatsSection(record, playerStats)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/vendored/sortablejs@1.15.6.min.js"></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamRosterSection renders the roster section — exported so it can be used for HTMX swaps
|
||||||
|
templ TeamRosterSection(twr *db.TeamWithRoster, available []*db.Player) {
|
||||||
|
{{
|
||||||
|
permCache := contexts.Permissions(ctx)
|
||||||
|
canManagePlayers := permCache.HasPermission(permissions.TeamsManagePlayers)
|
||||||
|
|
||||||
|
// Build the non-manager player list for display
|
||||||
|
rosterPlayers := []*db.Player{}
|
||||||
|
for _, p := range twr.Players {
|
||||||
|
if p != nil && (twr.Manager == nil || p.ID != twr.Manager.ID) {
|
||||||
|
rosterPlayers = append(rosterPlayers, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasRoster := twr.Manager != nil || len(rosterPlayers) > 0
|
||||||
|
}}
|
||||||
|
<section
|
||||||
|
id="team-roster-section"
|
||||||
|
x-data="{ showManageRosterModal: false }"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Roster</h2>
|
||||||
|
if canManagePlayers {
|
||||||
|
<button
|
||||||
|
@click="showManageRosterModal = true"
|
||||||
|
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
|
||||||
|
bg-blue hover:bg-blue/80 text-mantle transition"
|
||||||
|
>
|
||||||
|
Manage Players
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if !hasRoster {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No players on this roster yet.</p>
|
||||||
|
if canManagePlayers {
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Click "Manage Players" to add players to this team.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
|
if twr.Manager != nil {
|
||||||
|
<div class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<span class="text-text font-medium">{ twr.Manager.Name }</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
||||||
|
★ Manager
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
for _, player := range rosterPlayers {
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<span class="text-text">{ player.Name }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if canManagePlayers {
|
||||||
|
@manageRosterModal(twr, available, rosterPlayers)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ manageRosterModal(twr *db.TeamWithRoster, available []*db.Player, rosterPlayers []*db.Player) {
|
||||||
|
<div
|
||||||
|
x-show="showManageRosterModal"
|
||||||
|
@keydown.escape.window="showManageRosterModal = false"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
style="display: none;"
|
||||||
|
x-data="rosterManager()"
|
||||||
|
x-init="$watch('showManageRosterModal', val => { if (val) $nextTick(() => init()) })"
|
||||||
|
>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-crust/80 transition-opacity"
|
||||||
|
@click="showManageRosterModal = false"
|
||||||
|
></div>
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="flex min-h-full items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-4xl w-full p-6"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-text">Manage Players</h3>
|
||||||
|
<button
|
||||||
|
@click="showManageRosterModal = false"
|
||||||
|
class="text-subtext0 hover:text-text transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Two column layout -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Left: Available Players -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-text mb-3">Available Players</h4>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="search"
|
||||||
|
@input="applySearch()"
|
||||||
|
placeholder="Search players..."
|
||||||
|
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||||||
|
focus:border-blue focus:outline-none mb-3 text-sm"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="available-players-list"
|
||||||
|
class="bg-base border border-surface1 rounded-lg p-2 min-h-48 max-h-80 overflow-y-auto space-y-1"
|
||||||
|
>
|
||||||
|
for _, player := range available {
|
||||||
|
<div
|
||||||
|
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
|
||||||
|
text-text text-sm cursor-grab hover:bg-surface1 transition"
|
||||||
|
data-id={ fmt.Sprint(player.ID) }
|
||||||
|
data-name={ player.Name }
|
||||||
|
>
|
||||||
|
{ player.Name }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext1 text-xs mt-2">Drag players to the team roster</p>
|
||||||
|
</div>
|
||||||
|
<!-- Right: Team Roster -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-text mb-3">Team Roster</h4>
|
||||||
|
<!-- Manager slot -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="text-sm font-medium text-yellow mb-2 flex items-center gap-1">
|
||||||
|
★ Manager
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
id="manager-drop-zone"
|
||||||
|
class="bg-base border-2 border-dashed border-yellow/40 rounded-lg p-2 min-h-12
|
||||||
|
flex items-center justify-center"
|
||||||
|
>
|
||||||
|
if twr.Manager != nil {
|
||||||
|
<div
|
||||||
|
class="roster-player-chip px-3 py-2 bg-yellow/10 border border-yellow/30 rounded
|
||||||
|
text-text text-sm cursor-grab w-full"
|
||||||
|
data-id={ fmt.Sprint(twr.Manager.ID) }
|
||||||
|
data-name={ twr.Manager.Name }
|
||||||
|
>
|
||||||
|
{ twr.Manager.Name }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Players list -->
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-subtext0 mb-2 block">Players</label>
|
||||||
|
<div
|
||||||
|
id="roster-drop-zone"
|
||||||
|
class="bg-base border-2 border-dashed border-surface1 rounded-lg p-2 min-h-40
|
||||||
|
max-h-60 overflow-y-auto space-y-1"
|
||||||
|
>
|
||||||
|
for _, player := range rosterPlayers {
|
||||||
|
<div
|
||||||
|
class="roster-player-chip px-3 py-2 bg-surface0 border border-surface1 rounded
|
||||||
|
text-text text-sm cursor-grab hover:bg-surface1 transition"
|
||||||
|
data-id={ fmt.Sprint(player.ID) }
|
||||||
|
data-name={ player.Name }
|
||||||
|
>
|
||||||
|
{ player.Name }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-between items-center mt-6 pt-4 border-t border-surface1">
|
||||||
|
<p
|
||||||
|
x-show="!hasManager"
|
||||||
|
class="text-sm text-red"
|
||||||
|
>
|
||||||
|
A manager is required
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showManageRosterModal = false"
|
||||||
|
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
id="roster-submit-form"
|
||||||
|
hx-post="/teams/manage_roster"
|
||||||
|
hx-swap="none"
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="season_id" value={ fmt.Sprint(twr.Season.ID) }/>
|
||||||
|
<input type="hidden" name="league_id" value={ fmt.Sprint(twr.League.ID) }/>
|
||||||
|
<input type="hidden" name="team_id" value={ fmt.Sprint(twr.Team.ID) }/>
|
||||||
|
<input type="hidden" name="manager_id" value="0"/>
|
||||||
|
<!-- player_ids inputs are added dynamically by submitRoster() -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="submitRoster()"
|
||||||
|
:disabled="!hasManager"
|
||||||
|
class="px-4 py-2 rounded-lg text-mantle transition"
|
||||||
|
:class="hasManager ? 'bg-green hover:bg-green/75 hover:cursor-pointer' : 'bg-green/40 cursor-not-allowed'"
|
||||||
|
>
|
||||||
|
Save Roster
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function rosterManager() {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
hasManager: false,
|
||||||
|
sortableInstances: [],
|
||||||
|
updateHasManager() {
|
||||||
|
const zone = document.getElementById('manager-drop-zone');
|
||||||
|
this.hasManager = zone ? zone.querySelectorAll('.roster-player-chip').length > 0 : false;
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (typeof Sortable === 'undefined') return;
|
||||||
|
|
||||||
|
// Destroy any previous instances
|
||||||
|
this.sortableInstances.forEach(s => s.destroy());
|
||||||
|
this.sortableInstances = [];
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
this.sortableInstances.push(new Sortable(
|
||||||
|
document.getElementById('available-players-list'), {
|
||||||
|
group: { name: 'roster', pull: true, put: true },
|
||||||
|
sort: false,
|
||||||
|
animation: 150,
|
||||||
|
onAdd(evt) { self.applySearch(); self.updateHasManager(); },
|
||||||
|
onRemove(evt) { self.applySearch(); self.updateHasManager(); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.sortableInstances.push(new Sortable(
|
||||||
|
document.getElementById('manager-drop-zone'), {
|
||||||
|
group: { name: 'roster', pull: true, put: function(to) {
|
||||||
|
return to.el.querySelectorAll('.roster-player-chip').length === 0;
|
||||||
|
}},
|
||||||
|
sort: false,
|
||||||
|
animation: 150,
|
||||||
|
onAdd(evt) {
|
||||||
|
// Style the chip for manager zone
|
||||||
|
evt.item.classList.remove('bg-surface0', 'border-surface1');
|
||||||
|
evt.item.classList.add('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||||||
|
self.updateHasManager();
|
||||||
|
},
|
||||||
|
onRemove(evt) {
|
||||||
|
// Revert style
|
||||||
|
evt.item.classList.add('bg-surface0', 'border-surface1');
|
||||||
|
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||||||
|
self.updateHasManager();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.sortableInstances.push(new Sortable(
|
||||||
|
document.getElementById('roster-drop-zone'), {
|
||||||
|
group: { name: 'roster', pull: true, put: true },
|
||||||
|
sort: true,
|
||||||
|
animation: 150,
|
||||||
|
onAdd(evt) {
|
||||||
|
// Ensure surface styling
|
||||||
|
evt.item.classList.add('bg-surface0', 'border-surface1');
|
||||||
|
evt.item.classList.remove('bg-yellow/10', 'border-yellow/30', 'w-full');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.updateHasManager();
|
||||||
|
this.applySearch();
|
||||||
|
},
|
||||||
|
applySearch() {
|
||||||
|
const s = this.search.toLowerCase();
|
||||||
|
const list = document.getElementById('available-players-list');
|
||||||
|
if (!list) return;
|
||||||
|
const chips = list.querySelectorAll('.roster-player-chip');
|
||||||
|
chips.forEach(chip => {
|
||||||
|
const name = (chip.dataset.name || '').toLowerCase();
|
||||||
|
if (s === '' || name.includes(s)) {
|
||||||
|
chip.style.display = '';
|
||||||
|
} else {
|
||||||
|
chip.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
submitRoster() {
|
||||||
|
if (!this.hasManager) return;
|
||||||
|
|
||||||
|
const managerZone = document.getElementById('manager-drop-zone');
|
||||||
|
const rosterZone = document.getElementById('roster-drop-zone');
|
||||||
|
|
||||||
|
const managerChip = managerZone ? managerZone.querySelector('.roster-player-chip') : null;
|
||||||
|
const managerID = managerChip ? managerChip.dataset.id : '0';
|
||||||
|
|
||||||
|
const rosterChips = rosterZone ? rosterZone.querySelectorAll('.roster-player-chip') : [];
|
||||||
|
const playerIDs = Array.from(rosterChips).map(el => el.dataset.id);
|
||||||
|
|
||||||
|
// Build a hidden form dynamically and submit via HTMX
|
||||||
|
const form = document.getElementById('roster-submit-form');
|
||||||
|
// Clear previous dynamic inputs
|
||||||
|
form.querySelectorAll('.dynamic-input').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Set manager ID
|
||||||
|
form.querySelector('[name="manager_id"]').value = managerID;
|
||||||
|
|
||||||
|
// Add player_ids as multiple hidden inputs
|
||||||
|
playerIDs.forEach(id => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'player_ids';
|
||||||
|
input.value = id;
|
||||||
|
input.className = 'dynamic-input';
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger HTMX request on the form
|
||||||
|
htmx.trigger(form, 'submit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamFixturesPane(team *db.Team, fixtures []*db.Fixture, scheduleMap map[int]*db.FixtureSchedule, resultMap map[int]*db.FixtureResult) {
|
||||||
|
{{
|
||||||
|
// Split fixtures into upcoming and completed
|
||||||
|
var upcoming []*db.Fixture
|
||||||
|
var completed []*db.Fixture
|
||||||
|
for _, f := range fixtures {
|
||||||
|
if _, hasResult := resultMap[f.ID]; hasResult {
|
||||||
|
completed = append(completed, f)
|
||||||
|
} else {
|
||||||
|
upcoming = append(upcoming, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort completed by scheduled time descending (most recent first)
|
||||||
|
sort.Slice(completed, func(i, j int) bool {
|
||||||
|
ti := time.Time{}
|
||||||
|
tj := time.Time{}
|
||||||
|
if si, ok := scheduleMap[completed[i].ID]; ok && si.ScheduledTime != nil {
|
||||||
|
ti = *si.ScheduledTime
|
||||||
|
}
|
||||||
|
if sj, ok := scheduleMap[completed[j].ID]; ok && sj.ScheduledTime != nil {
|
||||||
|
tj = *sj.ScheduledTime
|
||||||
|
}
|
||||||
|
return ti.After(tj)
|
||||||
|
})
|
||||||
|
// Limit to 5 most recent results
|
||||||
|
recentResults := completed
|
||||||
|
if len(recentResults) > 5 {
|
||||||
|
recentResults = recentResults[:5]
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<section class="space-y-6">
|
||||||
|
<!-- Results -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-text mb-4">Results</h2>
|
||||||
|
if len(recentResults) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No results yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Match results will appear here once games are played.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
|
for _, fixture := range recentResults {
|
||||||
|
@teamResultRow(team, fixture, resultMap)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!-- Upcoming -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-text mb-4">Upcoming</h2>
|
||||||
|
if len(upcoming) == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No upcoming fixtures.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden divide-y divide-surface1">
|
||||||
|
for _, fixture := range upcoming {
|
||||||
|
@teamFixtureRow(team, fixture, scheduleMap)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamFixtureRow(team *db.Team, fixture *db.Fixture, scheduleMap map[int]*db.FixtureSchedule) {
|
||||||
|
{{
|
||||||
|
isHome := fixture.HomeTeamID == team.ID
|
||||||
|
var opponent string
|
||||||
|
if isHome {
|
||||||
|
opponent = fixture.AwayTeam.Name
|
||||||
|
} else {
|
||||||
|
opponent = fixture.HomeTeam.Name
|
||||||
|
}
|
||||||
|
sched, hasSchedule := scheduleMap[fixture.ID]
|
||||||
|
_ = sched
|
||||||
|
}}
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||||
|
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||||||
|
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||||||
|
</span>
|
||||||
|
if isHome {
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
|
||||||
|
HOME
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
|
||||||
|
AWAY
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-sm text-subtext0 shrink-0">vs</span>
|
||||||
|
<span class="text-text font-medium truncate">
|
||||||
|
{ opponent }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
if hasSchedule && sched.ScheduledTime != nil {
|
||||||
|
<span class="text-xs text-green font-medium shrink-0">
|
||||||
|
@localtime(sched.ScheduledTime, "short")
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs text-subtext1 shrink-0">
|
||||||
|
TBD
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamResultRow(team *db.Team, fixture *db.Fixture, resultMap map[int]*db.FixtureResult) {
|
||||||
|
{{
|
||||||
|
isHome := fixture.HomeTeamID == team.ID
|
||||||
|
var opponent string
|
||||||
|
if isHome {
|
||||||
|
opponent = fixture.AwayTeam.Name
|
||||||
|
} else {
|
||||||
|
opponent = fixture.HomeTeam.Name
|
||||||
|
}
|
||||||
|
res := resultMap[fixture.ID]
|
||||||
|
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
|
||||||
|
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
|
||||||
|
_ = lost
|
||||||
|
}}
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d", fixture.ID)) }
|
||||||
|
class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface1 transition hover:cursor-pointer block"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
if won {
|
||||||
|
<span class="text-xs font-bold px-2 py-0.5 bg-green/20 text-green rounded shrink-0">W</span>
|
||||||
|
} else if lost {
|
||||||
|
<span class="text-xs font-bold px-2 py-0.5 bg-red/20 text-red rounded shrink-0">L</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs font-bold px-2 py-0.5 bg-surface1 text-subtext0 rounded shrink-0">D</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded shrink-0">
|
||||||
|
GW{ fmt.Sprint(*fixture.GameWeek) }
|
||||||
|
</span>
|
||||||
|
if isHome {
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-blue/20 text-blue rounded font-medium shrink-0">
|
||||||
|
HOME
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-surface1 text-subtext0 rounded font-medium shrink-0">
|
||||||
|
AWAY
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="text-sm text-subtext0 shrink-0">vs</span>
|
||||||
|
<span class="text-text font-medium truncate">
|
||||||
|
{ opponent }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="flex items-center gap-2 shrink-0">
|
||||||
|
if res.Winner == "home" {
|
||||||
|
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||||
|
<span class="text-xs text-subtext0">–</span>
|
||||||
|
<span class="text-sm text-subtext0">{ fmt.Sprint(res.AwayScore) }</span>
|
||||||
|
} else if res.Winner == "away" {
|
||||||
|
<span class="text-sm text-subtext0">{ fmt.Sprint(res.HomeScore) }</span>
|
||||||
|
<span class="text-xs text-subtext0">–</span>
|
||||||
|
<span class="text-sm font-bold text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-sm text-text">{ fmt.Sprint(res.HomeScore) }</span>
|
||||||
|
<span class="text-xs text-subtext0">–</span>
|
||||||
|
<span class="text-sm text-text">{ fmt.Sprint(res.AwayScore) }</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ teamStatsSection(record *db.TeamRecord, playerStats []*db.AggregatedPlayerStats) {
|
||||||
|
<section>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-text">Stats</h2>
|
||||||
|
</div>
|
||||||
|
if record.Played == 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
|
||||||
|
<p class="text-subtext0 text-lg">No stats yet.</p>
|
||||||
|
<p class="text-subtext1 text-sm mt-2">Team statistics will appear here once games are played.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Team Record Summary -->
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden mb-4">
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 divide-x divide-surface1">
|
||||||
|
@statCell("Played", fmt.Sprint(record.Played), "")
|
||||||
|
@statCell("Record", fmt.Sprintf("%d-%d-%d", record.Wins, record.Losses, record.Draws), "")
|
||||||
|
@statCell("Wins", fmt.Sprint(record.Wins), "text-green")
|
||||||
|
@statCell("Losses", fmt.Sprint(record.Losses), "text-red")
|
||||||
|
@statCell("GF", fmt.Sprint(record.GoalsFor), "")
|
||||||
|
@statCell("GA", fmt.Sprint(record.GoalsAgainst), "")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Player Stats Leaderboard -->
|
||||||
|
if len(playerStats) > 0 {
|
||||||
|
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-mantle border-b border-surface1">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Games Played">GP</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||||||
|
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-surface1">
|
||||||
|
for _, ps := range playerStats {
|
||||||
|
<tr class="hover:bg-surface1 transition-colors">
|
||||||
|
<td class="px-3 py-2 text-sm text-text">{ ps.PlayerName }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-subtext0">{ fmt.Sprint(ps.GamesPlayed) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ fmt.Sprint(ps.Score) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Goals) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Assists) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Saves) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Shots) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Blocks) }</td>
|
||||||
|
<td class="px-2 py-2 text-center text-sm text-text">{ fmt.Sprint(ps.Passes) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ statCell(label string, value string, valueColor string) {
|
||||||
|
<div class="px-4 py-3 text-center">
|
||||||
|
<p class="text-xs text-subtext0 font-medium uppercase mb-1">{ label }</p>
|
||||||
|
<p class={ "text-lg font-bold", templ.KV("text-text", valueColor == ""), templ.KV(valueColor, valueColor != "") }>
|
||||||
|
{ value }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import "git.haelnorr.com/h/oslstats/internal/permissions"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ SeasonLeagueTeamsPage(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team) {
|
templ SeasonLeagueTeamsPage(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team, managers map[int]*db.Player) {
|
||||||
@SeasonLeagueLayout("teams", season, league) {
|
@SeasonLeagueLayout("teams", season, league) {
|
||||||
@SeasonLeagueTeams(season, league, teams, allTeams)
|
@SeasonLeagueTeams(season, league, teams, allTeams, managers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ templ addTeamModal(season *db.Season, league *db.League, existingTeams []*db.Tea
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team) {
|
templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team, allTeams []*db.Team, managers map[int]*db.Player) {
|
||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
permCache := contexts.Permissions(ctx)
|
||||||
canAddTeam := permCache.HasPermission(permissions.TeamsAddToLeague)
|
canAddTeam := permCache.HasPermission(permissions.TeamsAddToLeague)
|
||||||
@@ -122,7 +122,13 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
|
|||||||
} else {
|
} else {
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
for _, team := range teams {
|
for _, team := range teams {
|
||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors">
|
{{
|
||||||
|
manager := managers[team.ID]
|
||||||
|
}}
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/teams/%d", season.ShortName, league.ShortName, team.ID)) }
|
||||||
|
class="bg-surface0 border border-surface1 rounded-lg p-6 hover:bg-surface1 transition-colors hover:cursor-pointer block"
|
||||||
|
>
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
|
<h3 class="text-xl font-bold text-text">{ team.Name }</h3>
|
||||||
if team.Color != "" {
|
if team.Color != "" {
|
||||||
@@ -132,7 +138,7 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
|
|||||||
></div>
|
></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
<div class="flex items-center gap-2 text-sm flex-wrap mb-3">
|
||||||
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono">
|
<span class="px-2 py-1 bg-mantle rounded text-subtext0 font-mono">
|
||||||
{ team.ShortName }
|
{ team.ShortName }
|
||||||
</span>
|
</span>
|
||||||
@@ -140,8 +146,30 @@ templ SeasonLeagueTeams(season *db.Season, league *db.League, teams []*db.Team,
|
|||||||
{ team.AltShortName }
|
{ team.AltShortName }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Roster -->
|
||||||
|
if len(team.Players) == 0 {
|
||||||
|
<p class="text-subtext1 text-xs italic">No roster</p>
|
||||||
|
} else {
|
||||||
|
<div class="border-t border-surface1 pt-3 space-y-1">
|
||||||
|
if manager != nil {
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-text font-medium">{ manager.Name }</span>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-yellow/20 text-yellow rounded font-medium">
|
||||||
|
★ Mgr
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
for _, player := range team.Players {
|
||||||
|
if manager == nil || player.ID != manager.ID {
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-subtext0">{ player.Name }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if canAddTeam {
|
if canAddTeam {
|
||||||
|
|||||||
7
internal/view/seasonsview/types.go
Normal file
7
internal/view/seasonsview/types.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package seasonsview
|
||||||
|
|
||||||
|
// FreeAgentWarning holds information about a free agent nomination issue for display.
|
||||||
|
type FreeAgentWarning struct {
|
||||||
|
Name string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
37
pkg/slapshotapi/client.go
Normal file
37
pkg/slapshotapi/client.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SlapAPI struct {
|
||||||
|
client *http.Client
|
||||||
|
ratelimiter *rate.Limiter
|
||||||
|
mu sync.Mutex
|
||||||
|
maxTokens int
|
||||||
|
key string
|
||||||
|
env string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSlapAPIClient(cfg *Config) (*SlapAPI, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, errors.New("config cannot be nil")
|
||||||
|
}
|
||||||
|
if cfg.Environment != "api" && cfg.Environment != "staging" {
|
||||||
|
return nil, errors.New("invalid env specified, must be 'api' or 'staging'")
|
||||||
|
}
|
||||||
|
rl := rate.NewLimiter(rate.Inf, 10)
|
||||||
|
client := &SlapAPI{
|
||||||
|
client: http.DefaultClient,
|
||||||
|
ratelimiter: rl,
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
maxTokens: 10,
|
||||||
|
key: cfg.Key,
|
||||||
|
env: cfg.Environment,
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
23
pkg/slapshotapi/config.go
Normal file
23
pkg/slapshotapi/config.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Package slapshotapi provides utilities for interacting with the slapshot public API
|
||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/env"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment string // ENV SLAPSHOT_ENVIRONMENT: API environment to connect to (default: staging)
|
||||||
|
Key string // ENV SLAPSHOT_API_KEY: API Key for authorisation with the API (required)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromEnv() (any, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
Environment: env.String("SLAPSHOT_ENVIRONMENT", "staging"),
|
||||||
|
Key: env.String("SLAPSHOT_API_KEY", ""),
|
||||||
|
}
|
||||||
|
if cfg.Key == "" {
|
||||||
|
return nil, errors.New("Envar not set: SLAPSHOT_API_KEY")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
79
pkg/slapshotapi/enums.go
Normal file
79
pkg/slapshotapi/enums.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
// Regions
|
||||||
|
const (
|
||||||
|
RegionEUWest = "eu-west"
|
||||||
|
RegionNAEast = "na-east"
|
||||||
|
RegionNACentral = "na-central"
|
||||||
|
RegionNAWest = "na-west"
|
||||||
|
RegionOCEEast = "oce-east"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Arenas - API format (used in API responses and lobby creation)
|
||||||
|
const (
|
||||||
|
ArenaSlapstadium = "Slapstadium"
|
||||||
|
ArenaSlapville = "Slapville"
|
||||||
|
ArenaSlapstadiumMini = "Slapstadium_mini"
|
||||||
|
ArenaTableHockey = "Table_Hockey"
|
||||||
|
ArenaColosseum = "Colosseum"
|
||||||
|
ArenaSlapvilleJumbo = "Slapville_Jumbo"
|
||||||
|
ArenaSlapstation = "Slapstation"
|
||||||
|
ArenaSlapstadiumXL = "Slapstadium_XL"
|
||||||
|
ArenaIsland = "Island"
|
||||||
|
ArenaObstacles = "Obstacles"
|
||||||
|
ArenaObstaclesXL = "Obstacles_XL"
|
||||||
|
ArenaCyberpuck = "Cyberpuck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Arenas - Display format (used in local match logs)
|
||||||
|
const (
|
||||||
|
ArenaDisplaySlapStadium = "Slap Stadium"
|
||||||
|
ArenaDisplaySlapville = "Slapville"
|
||||||
|
ArenaDisplaySlapStadiumMini = "Slap Stadium Mini"
|
||||||
|
ArenaDisplayTableHockey = "Table Hockey"
|
||||||
|
ArenaDisplayColosseum = "Colosseum"
|
||||||
|
ArenaDisplaySlapvilleJumbo = "Slapville Jumbo"
|
||||||
|
ArenaDisplaySlapstadiumXL = "Slapstadium XL"
|
||||||
|
ArenaDisplayIsland = "Island"
|
||||||
|
ArenaDisplayObstaclesXL = "Obstacles XL"
|
||||||
|
ArenaDisplayCyberpuck = "Cyberpuck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// End reasons
|
||||||
|
const (
|
||||||
|
EndReasonEndOfReg = "EndOfRegulation"
|
||||||
|
EndReasonOvertime = "Overtime"
|
||||||
|
EndReasonHomeTeamLeft = "HomeTeamLeft"
|
||||||
|
EndReasonAwayTeamLeft = "AwayTeamLeft"
|
||||||
|
EndReasonMercy = "MercyRule"
|
||||||
|
EndReasonTie = "Tie"
|
||||||
|
EndReasonForfeit = "Forfeit"
|
||||||
|
EndReasonCancelled = "Cancelled"
|
||||||
|
EndReasonUnknown = "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game modes
|
||||||
|
const (
|
||||||
|
GameModeHockey = "hockey"
|
||||||
|
GameModeDodgePuck = "dodgepuck"
|
||||||
|
GameModeTag = "tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Teams
|
||||||
|
const (
|
||||||
|
TeamHome = "home"
|
||||||
|
TeamAway = "away"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Match winners
|
||||||
|
const (
|
||||||
|
WinnerHome = "home"
|
||||||
|
WinnerAway = "away"
|
||||||
|
WinnerNone = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Match types
|
||||||
|
const (
|
||||||
|
MatchTypePublic = "public"
|
||||||
|
MatchTypePrivate = "private"
|
||||||
|
)
|
||||||
41
pkg/slapshotapi/ezconf.go
Normal file
41
pkg/slapshotapi/ezconf.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||||
|
type EZConfIntegration struct {
|
||||||
|
configFunc func() (any, error)
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackagePath returns the path to the config package for source parsing
|
||||||
|
func (e EZConfIntegration) PackagePath() string {
|
||||||
|
_, filename, _, _ := runtime.Caller(0)
|
||||||
|
// Return directory of this file
|
||||||
|
return filename[:len(filename)-len("/ezconf.go")]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFunc returns the ConfigFromEnv function for ezconf
|
||||||
|
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
|
||||||
|
return func() (any, error) {
|
||||||
|
return e.configFunc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name to use when registering with ezconf
|
||||||
|
func (e EZConfIntegration) Name() string {
|
||||||
|
return strings.ToLower(e.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupName returns the display name for grouping environment variables
|
||||||
|
func (e EZConfIntegration) GroupName() string {
|
||||||
|
return e.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEZConfIntegration creates a new EZConf integration helper
|
||||||
|
func NewEZConfIntegration() EZConfIntegration {
|
||||||
|
return EZConfIntegration{name: "SlapshotAPI", configFunc: ConfigFromEnv}
|
||||||
|
}
|
||||||
53
pkg/slapshotapi/games.go
Normal file
53
pkg/slapshotapi/games.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type endpointGame struct {
|
||||||
|
gameID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointGame(gameID string) *endpointGame {
|
||||||
|
return &endpointGame{
|
||||||
|
gameID: gameID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointGame) path() string {
|
||||||
|
return fmt.Sprintf("/api/public/games/%s", ep.gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointGame) method() string {
|
||||||
|
return "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrGameNotFound is returned when a game ID does not match any known game
|
||||||
|
var ErrGameNotFound = errors.New("game not found")
|
||||||
|
|
||||||
|
// GetGame retrieves match details for a specific game by its ID.
|
||||||
|
func (c *SlapAPI) GetGame(
|
||||||
|
ctx context.Context,
|
||||||
|
gameID string,
|
||||||
|
) (*Game, error) {
|
||||||
|
if gameID == "" {
|
||||||
|
return nil, errors.New("gameID cannot be empty")
|
||||||
|
}
|
||||||
|
endpoint := getEndpointGame(gameID)
|
||||||
|
data, err := c.request(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "404") {
|
||||||
|
return nil, ErrGameNotFound
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "c.request")
|
||||||
|
}
|
||||||
|
game, err := unmarshal[Game](data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unmarshal")
|
||||||
|
}
|
||||||
|
return game, nil
|
||||||
|
}
|
||||||
59
pkg/slapshotapi/limiting.go
Normal file
59
pkg/slapshotapi/limiting.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *SlapAPI) do(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||||
|
for {
|
||||||
|
err := c.ratelimiter.Wait(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "c.ratelimiter.Wait")
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "c.client.Do")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
resetAfter := 30 * time.Second
|
||||||
|
err := resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "resp.Body.Close")
|
||||||
|
}
|
||||||
|
if resetAfter > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(resetAfter):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.updateLimiterFromHeaders(resp.Header)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlapAPI) updateLimiterFromHeaders(h http.Header) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
limit, err1 := strconv.Atoi(h.Get("RateLimit-Limit"))
|
||||||
|
window, err2 := strconv.Atoi(h.Get("RateLimit-Window"))
|
||||||
|
|
||||||
|
if err1 != nil || err2 != nil || limit <= 0 || window <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit != c.maxTokens || time.Duration(window) != time.Duration(float64(window)/float64(limit))*time.Second {
|
||||||
|
c.maxTokens = limit
|
||||||
|
c.ratelimiter.SetBurst(limit)
|
||||||
|
c.ratelimiter.SetLimit(rate.Every(time.Duration(window) / time.Duration(limit)))
|
||||||
|
}
|
||||||
|
}
|
||||||
187
pkg/slapshotapi/lobbies.go
Normal file
187
pkg/slapshotapi/lobbies.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Get Lobby ---
|
||||||
|
|
||||||
|
type endpointGetLobby struct {
|
||||||
|
lobbyID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointGetLobby(lobbyID string) *endpointGetLobby {
|
||||||
|
return &endpointGetLobby{lobbyID: lobbyID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointGetLobby) path() string {
|
||||||
|
return fmt.Sprintf("/api/public/lobbies/%s", ep.lobbyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointGetLobby) method() string {
|
||||||
|
return "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrLobbyNotFound is returned when a lobby ID does not match any known lobby
|
||||||
|
var ErrLobbyNotFound = errors.New("lobby not found")
|
||||||
|
|
||||||
|
// GetLobby retrieves details for a specific lobby by its ID.
|
||||||
|
func (c *SlapAPI) GetLobby(
|
||||||
|
ctx context.Context,
|
||||||
|
lobbyID string,
|
||||||
|
) (*Lobby, error) {
|
||||||
|
if lobbyID == "" {
|
||||||
|
return nil, errors.New("lobbyID cannot be empty")
|
||||||
|
}
|
||||||
|
endpoint := getEndpointGetLobby(lobbyID)
|
||||||
|
data, err := c.request(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "404") {
|
||||||
|
return nil, ErrLobbyNotFound
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "c.request")
|
||||||
|
}
|
||||||
|
lobby, err := unmarshal[Lobby](data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unmarshal")
|
||||||
|
}
|
||||||
|
return lobby, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get Lobby Matches ---
|
||||||
|
|
||||||
|
type endpointGetLobbyMatches struct {
|
||||||
|
lobbyID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointGetLobbyMatches(lobbyID string) *endpointGetLobbyMatches {
|
||||||
|
return &endpointGetLobbyMatches{lobbyID: lobbyID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointGetLobbyMatches) path() string {
|
||||||
|
return fmt.Sprintf("/api/public/lobbies/%s/matches", ep.lobbyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointGetLobbyMatches) method() string {
|
||||||
|
return "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLobbyMatches retrieves the list of matches played in a specific lobby.
|
||||||
|
func (c *SlapAPI) GetLobbyMatches(
|
||||||
|
ctx context.Context,
|
||||||
|
lobbyID string,
|
||||||
|
) ([]Game, error) {
|
||||||
|
if lobbyID == "" {
|
||||||
|
return nil, errors.New("lobbyID cannot be empty")
|
||||||
|
}
|
||||||
|
endpoint := getEndpointGetLobbyMatches(lobbyID)
|
||||||
|
data, err := c.request(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "404") {
|
||||||
|
return nil, ErrLobbyNotFound
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "c.request")
|
||||||
|
}
|
||||||
|
var games []Game
|
||||||
|
err = json.Unmarshal(data, &games)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "json.Unmarshal")
|
||||||
|
}
|
||||||
|
return games, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create Lobby ---
|
||||||
|
|
||||||
|
type endpointCreateLobby struct {
|
||||||
|
request LobbyCreationRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointCreateLobby(req LobbyCreationRequest) *endpointCreateLobby {
|
||||||
|
return &endpointCreateLobby{request: req}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointCreateLobby) path() string {
|
||||||
|
return "/api/public/lobbies"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointCreateLobby) method() string {
|
||||||
|
return "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointCreateLobby) body() ([]byte, error) {
|
||||||
|
data, err := json.Marshal(ep.request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "json.Marshal")
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLobby creates a new custom lobby with the specified settings.
|
||||||
|
func (c *SlapAPI) CreateLobby(
|
||||||
|
ctx context.Context,
|
||||||
|
req LobbyCreationRequest,
|
||||||
|
) (*LobbyCreationResponse, error) {
|
||||||
|
if req.Region == "" {
|
||||||
|
return nil, errors.New("region cannot be empty")
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
return nil, errors.New("name cannot be empty")
|
||||||
|
}
|
||||||
|
if req.CreatorName == "" {
|
||||||
|
return nil, errors.New("creator_name cannot be empty")
|
||||||
|
}
|
||||||
|
endpoint := getEndpointCreateLobby(req)
|
||||||
|
data, err := c.request(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "c.request")
|
||||||
|
}
|
||||||
|
resp, err := unmarshal[LobbyCreationResponse](data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unmarshal")
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete Lobby ---
|
||||||
|
|
||||||
|
type endpointDeleteLobby struct {
|
||||||
|
lobbyID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointDeleteLobby(lobbyID string) *endpointDeleteLobby {
|
||||||
|
return &endpointDeleteLobby{lobbyID: lobbyID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointDeleteLobby) path() string {
|
||||||
|
return fmt.Sprintf("/api/public/lobbies/%s", ep.lobbyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointDeleteLobby) method() string {
|
||||||
|
return "DELETE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLobby deletes an existing lobby by its ID.
|
||||||
|
// Returns true if the lobby was successfully deleted.
|
||||||
|
func (c *SlapAPI) DeleteLobby(
|
||||||
|
ctx context.Context,
|
||||||
|
lobbyID string,
|
||||||
|
) (bool, error) {
|
||||||
|
if lobbyID == "" {
|
||||||
|
return false, errors.New("lobbyID cannot be empty")
|
||||||
|
}
|
||||||
|
endpoint := getEndpointDeleteLobby(lobbyID)
|
||||||
|
status, body, err := c.requestRaw(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "c.requestRaw")
|
||||||
|
}
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return false, ErrLobbyNotFound
|
||||||
|
}
|
||||||
|
return string(body) == "OK", nil
|
||||||
|
}
|
||||||
64
pkg/slapshotapi/matchlog.go
Normal file
64
pkg/slapshotapi/matchlog.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchLog represents the raw JSON format of a local match log file.
|
||||||
|
// This differs slightly from the API Game response in structure — the
|
||||||
|
// match log is a flat object with game stats at the top level, whereas
|
||||||
|
// the API response wraps game stats inside a nested "game_stats" field
|
||||||
|
// alongside metadata like region, match type, and creation time.
|
||||||
|
type MatchLog struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MatchID string `json:"match_id,omitempty"`
|
||||||
|
Winner string `json:"winner"`
|
||||||
|
Arena string `json:"arena"`
|
||||||
|
PeriodsEnabled string `json:"periods_enabled"`
|
||||||
|
CurrentPeriod string `json:"current_period"`
|
||||||
|
CustomMercyRule string `json:"custom_mercy_rule"`
|
||||||
|
MatchLength string `json:"match_length"`
|
||||||
|
EndReason string `json:"end_reason"`
|
||||||
|
Score Score `json:"score"`
|
||||||
|
Players []Player `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMatchLog parses a local match log JSON file into a MatchLog struct.
|
||||||
|
func ParseMatchLog(data []byte) (*MatchLog, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, errors.New("data cannot be empty")
|
||||||
|
}
|
||||||
|
log, err := unmarshal[MatchLog](data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unmarshal")
|
||||||
|
}
|
||||||
|
return log, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToGameStats converts a MatchLog into a GameStats struct, normalizing the
|
||||||
|
// local match log format into the same structure used by the API.
|
||||||
|
func (ml *MatchLog) ToGameStats() GameStats {
|
||||||
|
return GameStats{
|
||||||
|
Type: ml.Type,
|
||||||
|
Arena: ml.Arena,
|
||||||
|
Score: ml.Score,
|
||||||
|
Winner: ml.Winner,
|
||||||
|
EndReason: ml.EndReason,
|
||||||
|
MatchLength: ml.MatchLength,
|
||||||
|
PeriodsEnabled: ml.PeriodsEnabled,
|
||||||
|
CurrentPeriod: ml.CurrentPeriod,
|
||||||
|
CustomMercyRule: ml.CustomMercyRule,
|
||||||
|
Players: ml.Players,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToGame converts a MatchLog into a Game struct. Since match logs don't
|
||||||
|
// contain all fields present in an API response (region, match_type,
|
||||||
|
// gamemode, created), those fields will be empty. The match_id from the
|
||||||
|
// log (if present) is used as the Game ID.
|
||||||
|
func (ml *MatchLog) ToGame() Game {
|
||||||
|
return Game{
|
||||||
|
ID: ml.MatchID,
|
||||||
|
GameStats: ml.ToGameStats(),
|
||||||
|
}
|
||||||
|
}
|
||||||
63
pkg/slapshotapi/queuestatus.go
Normal file
63
pkg/slapshotapi/queuestatus.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type endpointMatchmaking struct {
|
||||||
|
regions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointMatchmaking(regions []string) *endpointMatchmaking {
|
||||||
|
return &endpointMatchmaking{
|
||||||
|
regions: regions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointMatchmaking) path() string {
|
||||||
|
path := "/api/public/matchmaking%s"
|
||||||
|
filters := ""
|
||||||
|
if len(ep.regions) > 0 {
|
||||||
|
filters = "?regions="
|
||||||
|
for i, region := range ep.regions {
|
||||||
|
filters = filters + region
|
||||||
|
if i+1 != len(ep.regions) {
|
||||||
|
filters = filters + ","
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(path, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointMatchmaking) method() string {
|
||||||
|
return "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
type matchmakingresp struct {
|
||||||
|
Playlists PubsQueue `json:"playlists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PubsQueue struct {
|
||||||
|
InQueue uint16 `json:"in_queue"`
|
||||||
|
InMatch uint16 `json:"in_match"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueueStatus gets the number of players in public matchmaking
|
||||||
|
func (c *SlapAPI) GetQueueStatus(
|
||||||
|
ctx context.Context,
|
||||||
|
regions []string,
|
||||||
|
) (*PubsQueue, error) {
|
||||||
|
endpoint := getEndpointMatchmaking(regions)
|
||||||
|
data, err := c.request(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "c.request")
|
||||||
|
}
|
||||||
|
resp, err := unmarshal[matchmakingresp](data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unmarshal")
|
||||||
|
}
|
||||||
|
return &resp.Playlists, nil
|
||||||
|
}
|
||||||
116
pkg/slapshotapi/request.go
Normal file
116
pkg/slapshotapi/request.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type endpoint interface {
|
||||||
|
path() string
|
||||||
|
method() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyEndpoint is an optional interface for endpoints that send a JSON body
|
||||||
|
type bodyEndpoint interface {
|
||||||
|
endpoint
|
||||||
|
body() ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlapAPI) request(
|
||||||
|
ctx context.Context,
|
||||||
|
ep endpoint,
|
||||||
|
) ([]byte, error) {
|
||||||
|
baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path())
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if bep, ok := ep.(bodyEndpoint); ok {
|
||||||
|
data, err := bep.body()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "endpoint.body")
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, ep.method(), baseurl, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "http.NewRequestWithContext")
|
||||||
|
}
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key))
|
||||||
|
if bodyReader != nil {
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.do(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "c.do")
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New(fmt.Sprintf("API request failed with status %d", res.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "io.ReadAll")
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestRaw performs an API request and returns the raw status code and body.
|
||||||
|
// This is used for endpoints where non-200 status codes carry meaningful data
|
||||||
|
// (e.g. DELETE returning a plain text response).
|
||||||
|
func (c *SlapAPI) requestRaw(
|
||||||
|
ctx context.Context,
|
||||||
|
ep endpoint,
|
||||||
|
) (int, []byte, error) {
|
||||||
|
baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path())
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if bep, ok := ep.(bodyEndpoint); ok {
|
||||||
|
data, err := bep.body()
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, errors.Wrap(err, "endpoint.body")
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, ep.method(), baseurl, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, errors.Wrap(err, "http.NewRequestWithContext")
|
||||||
|
}
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key))
|
||||||
|
if bodyReader != nil {
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.do(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, errors.Wrap(err, "c.do")
|
||||||
|
}
|
||||||
|
defer func() { _ = res.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, errors.Wrap(err, "io.ReadAll")
|
||||||
|
}
|
||||||
|
return res.StatusCode, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshal is a helper that unmarshals JSON response data into a target struct
|
||||||
|
func unmarshal[T any](data []byte) (*T, error) {
|
||||||
|
var result T
|
||||||
|
err := json.Unmarshal(data, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "json.Unmarshal")
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
53
pkg/slapshotapi/slapid.go
Normal file
53
pkg/slapshotapi/slapid.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type endpointSteamID struct {
|
||||||
|
steamID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEndpointSteamID(steamID string) *endpointSteamID {
|
||||||
|
return &endpointSteamID{
|
||||||
|
steamID: steamID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointSteamID) path() string {
|
||||||
|
return fmt.Sprintf("/api/public/players/steam/%s", ep.steamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ep *endpointSteamID) method() string {
|
||||||
|
return "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
type idresp struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNoSlapID error = errors.New("slapID not found")
|
||||||
|
|
||||||
|
// GetSlapID returns the slapshot ID of the steam user.
|
||||||
|
func (c *SlapAPI) GetSlapID(
|
||||||
|
ctx context.Context,
|
||||||
|
steamid string,
|
||||||
|
) (uint32, error) {
|
||||||
|
endpoint := getEndpointSteamID(steamid)
|
||||||
|
data, err := c.request(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "404") {
|
||||||
|
return 0, ErrNoSlapID
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(err, "c.request")
|
||||||
|
}
|
||||||
|
resp, err := unmarshal[idresp](data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "unmarshal")
|
||||||
|
}
|
||||||
|
return resp.ID, nil
|
||||||
|
}
|
||||||
180
pkg/slapshotapi/tampering.go
Normal file
180
pkg/slapshotapi/tampering.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectTampering analyzes 3 period logs for modification signs.
|
||||||
|
// Returns (isTampering bool, reason string, error).
|
||||||
|
func DetectTampering(logs []*MatchLog) (bool, string, error) {
|
||||||
|
if len(logs) != 3 {
|
||||||
|
return false, "", errors.New("exactly 3 period logs are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons := []string{}
|
||||||
|
|
||||||
|
// Check metadata consistency
|
||||||
|
tampered, reason, err := ValidateMetadataConsistency(logs)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", errors.Wrap(err, "ValidateMetadataConsistency")
|
||||||
|
}
|
||||||
|
if tampered {
|
||||||
|
reasons = append(reasons, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check score progression
|
||||||
|
tampered, reason, err = ValidateScoreProgression(logs)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", errors.Wrap(err, "ValidateScoreProgression")
|
||||||
|
}
|
||||||
|
if tampered {
|
||||||
|
reasons = append(reasons, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check player consistency
|
||||||
|
tampered, reason, err = ValidatePlayerConsistency(logs)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", errors.Wrap(err, "ValidatePlayerConsistency")
|
||||||
|
}
|
||||||
|
if tampered {
|
||||||
|
reasons = append(reasons, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reasons) > 0 {
|
||||||
|
return true, strings.Join(reasons, "; "), nil
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateMetadataConsistency checks that arena, match_length, and
|
||||||
|
// custom_mercy_rule are consistent across all periods, and warns if any
|
||||||
|
// period has periods_enabled set to "False" (periods disabled).
|
||||||
|
func ValidateMetadataConsistency(logs []*MatchLog) (bool, string, error) {
|
||||||
|
if len(logs) != 3 {
|
||||||
|
return false, "", errors.New("exactly 3 period logs are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := logs[0]
|
||||||
|
inconsistencies := []string{}
|
||||||
|
|
||||||
|
for i := 1; i < len(logs); i++ {
|
||||||
|
log := logs[i]
|
||||||
|
if log.Arena != ref.Arena {
|
||||||
|
inconsistencies = append(inconsistencies,
|
||||||
|
fmt.Sprintf("arena differs in period %d (%q vs %q)", i+1, log.Arena, ref.Arena))
|
||||||
|
}
|
||||||
|
if log.MatchLength != ref.MatchLength {
|
||||||
|
inconsistencies = append(inconsistencies,
|
||||||
|
fmt.Sprintf("match_length differs in period %d (%s vs %s)", i+1, log.MatchLength, ref.MatchLength))
|
||||||
|
}
|
||||||
|
if log.CustomMercyRule != ref.CustomMercyRule {
|
||||||
|
inconsistencies = append(inconsistencies,
|
||||||
|
fmt.Sprintf("custom_mercy_rule differs in period %d (%s vs %s)", i+1, log.CustomMercyRule, ref.CustomMercyRule))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if any period has periods disabled
|
||||||
|
for i, log := range logs {
|
||||||
|
if strings.EqualFold(log.PeriodsEnabled, "False") {
|
||||||
|
inconsistencies = append(inconsistencies,
|
||||||
|
fmt.Sprintf("periods_enabled is False in period %d", i+1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(inconsistencies) > 0 {
|
||||||
|
return true, "metadata inconsistency: " + strings.Join(inconsistencies, ", "), nil
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateScoreProgression checks that scores only increase or stay the same
|
||||||
|
// across periods (cumulative stats).
|
||||||
|
func ValidateScoreProgression(logs []*MatchLog) (bool, string, error) {
|
||||||
|
if len(logs) != 3 {
|
||||||
|
return false, "", errors.New("exactly 3 period logs are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
issues := []string{}
|
||||||
|
|
||||||
|
for i := 1; i < len(logs); i++ {
|
||||||
|
prev := logs[i-1]
|
||||||
|
curr := logs[i]
|
||||||
|
if curr.Score.Home < prev.Score.Home {
|
||||||
|
issues = append(issues,
|
||||||
|
fmt.Sprintf("home score decreased from period %d (%d) to period %d (%d)",
|
||||||
|
i, prev.Score.Home, i+1, curr.Score.Home))
|
||||||
|
}
|
||||||
|
if curr.Score.Away < prev.Score.Away {
|
||||||
|
issues = append(issues,
|
||||||
|
fmt.Sprintf("away score decreased from period %d (%d) to period %d (%d)",
|
||||||
|
i, prev.Score.Away, i+1, curr.Score.Away))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) > 0 {
|
||||||
|
return true, "score regression: " + strings.Join(issues, ", "), nil
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePlayerConsistency checks that player rosters remain relatively stable
|
||||||
|
// across periods. A player present in period 1 should ideally still be present
|
||||||
|
// in later periods. Drastic roster changes are suspicious.
|
||||||
|
func ValidatePlayerConsistency(logs []*MatchLog) (bool, string, error) {
|
||||||
|
if len(logs) != 3 {
|
||||||
|
return false, "", errors.New("exactly 3 period logs are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build player sets per period
|
||||||
|
periodPlayers := make([]map[string]string, 3) // game_user_id -> team
|
||||||
|
for i, log := range logs {
|
||||||
|
periodPlayers[i] = make(map[string]string)
|
||||||
|
for _, p := range log.Players {
|
||||||
|
periodPlayers[i][p.GameUserID] = p.Team
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues := []string{}
|
||||||
|
|
||||||
|
// Check for team-switching between periods (same player, different team)
|
||||||
|
for i := 1; i < len(logs); i++ {
|
||||||
|
for id, prevTeam := range periodPlayers[i-1] {
|
||||||
|
if currTeam, exists := periodPlayers[i][id]; exists {
|
||||||
|
if currTeam != prevTeam {
|
||||||
|
issues = append(issues,
|
||||||
|
fmt.Sprintf("player %s switched teams between period %d and %d",
|
||||||
|
id, i, i+1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for drastic roster changes (more than 50% different players)
|
||||||
|
for i := 1; i < len(logs); i++ {
|
||||||
|
prev := periodPlayers[i-1]
|
||||||
|
curr := periodPlayers[i]
|
||||||
|
if len(prev) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
missing := 0
|
||||||
|
for id := range prev {
|
||||||
|
if _, exists := curr[id]; !exists {
|
||||||
|
missing++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ratio := float64(missing) / float64(len(prev))
|
||||||
|
if ratio > 0.5 {
|
||||||
|
issues = append(issues,
|
||||||
|
fmt.Sprintf("more than 50%% of players changed between period %d and %d (%d/%d missing)",
|
||||||
|
i, i+1, missing, len(prev)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) > 0 {
|
||||||
|
return true, "player inconsistency: " + strings.Join(issues, ", "), nil
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
158
pkg/slapshotapi/types.go
Normal file
158
pkg/slapshotapi/types.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package slapshotapi
|
||||||
|
|
||||||
|
// Score represents the score for both teams in a match
|
||||||
|
type Score struct {
|
||||||
|
Home int `json:"home"`
|
||||||
|
Away int `json:"away"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerStats contains all possible player statistics from a match.
|
||||||
|
// Fields use pointers because not every stat is present in every match response.
|
||||||
|
type PlayerStats struct {
|
||||||
|
Goals *float64 `json:"goals,omitempty"`
|
||||||
|
Assists *float64 `json:"assists,omitempty"`
|
||||||
|
PrimaryAssists *float64 `json:"primary_assists,omitempty"`
|
||||||
|
SecondaryAssists *float64 `json:"secondary_assists,omitempty"`
|
||||||
|
Saves *float64 `json:"saves,omitempty"`
|
||||||
|
Blocks *float64 `json:"blocks,omitempty"`
|
||||||
|
Shots *float64 `json:"shots,omitempty"`
|
||||||
|
Turnovers *float64 `json:"turnovers,omitempty"`
|
||||||
|
Takeaways *float64 `json:"takeaways,omitempty"`
|
||||||
|
Passes *float64 `json:"passes,omitempty"`
|
||||||
|
PossessionTime *float64 `json:"possession_time_sec,omitempty"`
|
||||||
|
FaceoffsWon *float64 `json:"faceoffs_won,omitempty"`
|
||||||
|
FaceoffsLost *float64 `json:"faceoffs_lost,omitempty"`
|
||||||
|
PostHits *float64 `json:"post_hits,omitempty"`
|
||||||
|
OvertimeGoals *float64 `json:"overtime_goals,omitempty"`
|
||||||
|
GameWinningGoals *float64 `json:"game_winning_goals,omitempty"`
|
||||||
|
Score *float64 `json:"score,omitempty"`
|
||||||
|
ContributedGoals *float64 `json:"contributed_goals,omitempty"`
|
||||||
|
ConcededGoals *float64 `json:"conceded_goals,omitempty"`
|
||||||
|
GamesPlayed *float64 `json:"games_played,omitempty"`
|
||||||
|
Wins *float64 `json:"wins,omitempty"`
|
||||||
|
Losses *float64 `json:"losses,omitempty"`
|
||||||
|
OvertimeWins *float64 `json:"overtime_wins,omitempty"`
|
||||||
|
OvertimeLosses *float64 `json:"overtime_losses,omitempty"`
|
||||||
|
Ties *float64 `json:"ties,omitempty"`
|
||||||
|
Shutouts *float64 `json:"shutouts,omitempty"`
|
||||||
|
ShutsAgainst *float64 `json:"shutouts_against,omitempty"`
|
||||||
|
HasMercyRuled *float64 `json:"has_mercy_ruled,omitempty"`
|
||||||
|
WasMercyRuled *float64 `json:"was_mercy_ruled,omitempty"`
|
||||||
|
PeriodsPlayed *float64 `json:"periods_played,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player represents a single player's data in a match
|
||||||
|
type Player struct {
|
||||||
|
GameUserID string `json:"game_user_id"`
|
||||||
|
Team string `json:"team"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Stats PlayerStats `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameStats contains the in-game statistics and settings for a match.
|
||||||
|
// This is the core match data returned both by the API and in local match logs.
|
||||||
|
type GameStats struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Arena string `json:"arena"`
|
||||||
|
Score Score `json:"score"`
|
||||||
|
Winner string `json:"winner"`
|
||||||
|
EndReason string `json:"end_reason"`
|
||||||
|
MatchLength string `json:"match_length"`
|
||||||
|
PeriodsEnabled string `json:"periods_enabled"`
|
||||||
|
CurrentPeriod string `json:"current_period"`
|
||||||
|
CustomMercyRule string `json:"custom_mercy_rule"`
|
||||||
|
Players []Player `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game represents a full match as returned by the API /games/{id} endpoint
|
||||||
|
type Game struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
Gamemode string `json:"gamemode"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
GameStats GameStats `json:"game_stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LobbyPlayer represents a player in a lobby
|
||||||
|
type LobbyPlayer struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LobbyTeams represents the team assignments in a lobby
|
||||||
|
type LobbyTeams struct {
|
||||||
|
Home []LobbyPlayer `json:"home"`
|
||||||
|
Away []LobbyPlayer `json:"away"`
|
||||||
|
Spectators []LobbyPlayer `json:"spectators"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lobby represents a custom lobby as returned by the API
|
||||||
|
type Lobby struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
Owner int `json:"owner"`
|
||||||
|
OwnerName string `json:"owner_name"`
|
||||||
|
PlayerCount int `json:"player_count"`
|
||||||
|
MaxPlayers int `json:"max_players"`
|
||||||
|
InGame bool `json:"in_game"`
|
||||||
|
Players LobbyTeams `json:"players"`
|
||||||
|
MercyRule int `json:"mercy_rule"`
|
||||||
|
Arena string `json:"arena"`
|
||||||
|
PeriodsEnabled bool `json:"periods_enabled"`
|
||||||
|
CurrentPeriod int `json:"current_period"`
|
||||||
|
Score Score `json:"score"`
|
||||||
|
Starting bool `json:"starting"`
|
||||||
|
CanStart bool `json:"can_start"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LobbyCreationRequest contains the parameters for creating a new lobby
|
||||||
|
type LobbyCreationRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CreatorName string `json:"creator_name"`
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
IsPeriods *bool `json:"is_periods,omitempty"`
|
||||||
|
CurrentPeriod *int `json:"current_period,omitempty"`
|
||||||
|
MatchLength *int `json:"match_length,omitempty"`
|
||||||
|
MercyRule *int `json:"mercy_rule,omitempty"`
|
||||||
|
Arena string `json:"arena,omitempty"`
|
||||||
|
|
||||||
|
InitialScore *Score `json:"initial_score,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LobbyCreationResponse is returned when a lobby is successfully created
|
||||||
|
type LobbyCreationResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
LobbyID string `json:"lobby_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchmakingPlayer represents a player in the matchmaking queue
|
||||||
|
type MatchmakingPlayer struct {
|
||||||
|
UUID int `json:"uuid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchmakingRegion represents a region in a matchmaking entity
|
||||||
|
type MatchmakingRegion struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchmakingEntity represents a group/party in the matchmaking queue
|
||||||
|
type MatchmakingEntity struct {
|
||||||
|
Players []MatchmakingPlayer `json:"players"`
|
||||||
|
Regions []MatchmakingRegion `json:"regions"`
|
||||||
|
MMR int `json:"mmr"`
|
||||||
|
MMROffset int `json:"mmr_offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchmakingResponse is the full matchmaking queue response from the API
|
||||||
|
type MatchmakingResponse struct {
|
||||||
|
Entities []MatchmakingEntity `json:"entities"`
|
||||||
|
Playlists PubsQueue `json:"playlists"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user