diff --git a/cmd/oslstats/db.go b/cmd/oslstats/db.go index f952cf0..0981b7d 100644 --- a/cmd/oslstats/db.go +++ b/cmd/oslstats/db.go @@ -33,10 +33,12 @@ func registerDBModels(conn *bun.DB) []any { (*db.RolePermission)(nil), (*db.UserRole)(nil), (*db.SeasonLeague)(nil), + (*db.TeamParticipation)(nil), (*db.User)(nil), (*db.DiscordToken)(nil), (*db.Season)(nil), (*db.League)(nil), + (*db.Team)(nil), (*db.Role)(nil), (*db.Permission)(nil), (*db.AuditLog)(nil), diff --git a/cmd/oslstats/migrations/20260211225253_teams.go b/cmd/oslstats/migrations/20260211225253_teams.go new file mode 100644 index 0000000..2b9d908 --- /dev/null +++ b/cmd/oslstats/migrations/20260211225253_teams.go @@ -0,0 +1,49 @@ +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, dbConn *bun.DB) error { + // Add your migration code here + _, err := dbConn.NewCreateTable(). + Model((*db.Team)(nil)). + Exec(ctx) + if err != nil { + return err + } + _, err = dbConn.NewCreateTable(). + Model((*db.TeamParticipation)(nil)). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + // DOWN migration + func(ctx context.Context, dbConn *bun.DB) error { + // Add your rollback code here + _, err := dbConn.NewDropTable(). + Model((*db.TeamParticipation)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + _, err = dbConn.NewDropTable(). + Model((*db.Team)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + ) +} diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 75e9af5..9b0abe1 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -31,11 +31,6 @@ func addRoutes( ) error { // Create the routes pageroutes := []hws.Route{ - { - Path: "/permtest", - Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, - Handler: handlers.PermTester(s, conn), - }, { Path: "/static/", Method: hws.MethodGET, @@ -108,6 +103,11 @@ func addRoutes( }, { Path: "/seasons/{season_short_name}/leagues/{league_short_name}", + Method: hws.MethodGET, + Handler: handlers.SeasonLeaguePage(s, conn), + }, + { + Path: "/seasons/{season_short_name}/leagues/add/{league_short_name}", Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.SeasonsAddLeague)(handlers.SeasonAddLeague(s, conn, audit)), }, @@ -116,6 +116,11 @@ func addRoutes( Method: hws.MethodDELETE, Handler: perms.RequirePermission(s, permissions.SeasonsRemoveLeague)(handlers.SeasonRemoveLeague(s, conn, audit)), }, + { + Path: "/seasons/{season_short_name}/leagues/{league_short_name}/teams/add", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn, audit)), + }, { Path: "/leagues", Method: hws.MethodGET, @@ -131,6 +136,26 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeagueSubmit(s, conn, audit)), }, + { + Path: "/teams", + Method: hws.MethodGET, + Handler: handlers.TeamsPage(s, conn), + }, + { + Path: "/teams", + Method: hws.MethodPOST, + Handler: handlers.TeamsList(s, conn), + }, + { + Path: "/teams/new", + Method: hws.MethodGET, + Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamPage(s, conn)), + }, + { + Path: "/teams/new", + Method: hws.MethodPOST, + Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn, audit)), + }, } htmxRoutes := []hws.Route{ @@ -159,6 +184,16 @@ func addRoutes( Method: hws.MethodPOST, Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "short_name"), }, + { + Path: "/htmx/isteamnameunique", + Method: hws.MethodPOST, + Handler: handlers.IsUnique(s, conn, (*db.Team)(nil), "name"), + }, + { + Path: "/htmx/isteamshortnamesunique", + Method: hws.MethodPOST, + Handler: handlers.IsTeamShortNamesUnique(s, conn), + }, } wsRoutes := []hws.Route{ diff --git a/internal/db/league.go b/internal/db/league.go index 9737354..9a96a36 100644 --- a/internal/db/league.go +++ b/internal/db/league.go @@ -16,6 +16,7 @@ type League struct { Description string `bun:"description"` Seasons []Season `bun:"m2m:season_leagues,join:League=Season"` + Teams []Team `bun:"m2m:team_participations,join:League=Team"` } type SeasonLeague struct { @@ -35,3 +36,42 @@ func GetLeague(ctx context.Context, tx bun.Tx, shortname string) (*League, error } return GetByField[League](tx, "short_name", shortname).Relation("Seasons").Get(ctx) } + +// GetSeasonLeague retrieves a specific season-league combination with teams +func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) { + if seasonShortName == "" { + return nil, nil, nil, errors.New("season short_name cannot be empty") + } + if leagueShortName == "" { + return nil, nil, nil, errors.New("league short_name cannot be empty") + } + + // Get the season + season, err := GetSeason(ctx, tx, seasonShortName) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "GetSeason") + } + + // Get the league + league, err := GetLeague(ctx, tx, leagueShortName) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "GetLeague") + } + if season == nil || league == nil || !season.HasLeague(league.ID) { + return nil, nil, nil, nil + } + + // Get all teams participating in this season+league + var teams []*Team + err = tx.NewSelect(). + Model(&teams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID). + Order("t.name ASC"). + Scan(ctx) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "tx.Select teams") + } + + return season, league, teams, nil +} diff --git a/internal/db/season.go b/internal/db/season.go index 9f8e364..3df536f 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -22,6 +22,7 @@ type Season struct { SlapVersion string `bun:"slap_version,notnull,default:'rebound'"` Leagues []League `bun:"m2m:season_leagues,join:Season=League"` + Teams []Team `bun:"m2m:team_participations,join:Season=Team"` } // NewSeason returns a new season. It does not add it to the database @@ -49,7 +50,7 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error if shortname == "" { return nil, errors.New("short_name not provided") } - return GetByField[Season](tx, "short_name", shortname).Relation("Leagues").Get(ctx) + return GetByField[Season](tx, "short_name", shortname).Relation("Leagues").Relation("Teams").Get(ctx) } // Update updates the season struct. It does not insert to the database @@ -66,3 +67,39 @@ func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time. s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24) } } + +func (s *Season) MapTeamsToLeagues(ctx context.Context, tx bun.Tx) ([]LeagueWithTeams, error) { + // For each league, get the teams + leaguesWithTeams := make([]LeagueWithTeams, len(s.Leagues)) + for i, league := range s.Leagues { + var teams []*Team + err := tx.NewSelect(). + Model(&teams). + Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id"). + Where("tp.season_id = ? AND tp.league_id = ?", s.ID, league.ID). + Order("t.name ASC"). + Scan(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + leaguesWithTeams[i] = LeagueWithTeams{ + League: &league, + Teams: teams, + } + } + return leaguesWithTeams, nil +} + +type LeagueWithTeams struct { + League *League + Teams []*Team +} + +func (s *Season) HasLeague(leagueID int) bool { + for _, league := range s.Leagues { + if league.ID == leagueID { + return true + } + } + return false +} diff --git a/internal/db/team.go b/internal/db/team.go new file mode 100644 index 0000000..b0cd882 --- /dev/null +++ b/internal/db/team.go @@ -0,0 +1,58 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type Team struct { + bun.BaseModel `bun:"table:teams,alias:t"` + ID int `bun:"id,pk,autoincrement"` + Name string `bun:"name,unique,notnull"` + ShortName string `bun:"short_name,notnull,unique:short_names"` + AltShortName string `bun:"alt_short_name,notnull,unique:short_names"` + Color string `bun:"color"` + + Seasons []Season `bun:"m2m:team_participations,join:Team=Season"` + Leagues []League `bun:"m2m:team_participations,join:Team=League"` +} + +type TeamParticipation struct { + SeasonID int `bun:",pk,unique:season_team"` + Season *Season `bun:"rel:belongs-to,join:season_id=id"` + LeagueID int `bun:",pk"` + League *League `bun:"rel:belongs-to,join:league_id=id"` + TeamID int `bun:",pk,unique:season_team"` + Team *Team `bun:"rel:belongs-to,join:team_id=id"` +} + +func ListTeams(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Team], error) { + defaults := &PageOpts{ + 1, + 10, + bun.OrderAsc, + "name", + } + return GetList[Team](tx).GetPaged(ctx, pageOpts, defaults) +} + +func GetTeam(ctx context.Context, tx bun.Tx, id int) (*Team, error) { + if id == 0 { + return nil, errors.New("id not provided") + } + return GetByID[Team](tx, id).Relation("Seasons").Relation("Leagues").Get(ctx) +} + +func TeamShortNamesUnique(ctx context.Context, tx bun.Tx, shortname, altshortname string) (bool, error) { + // Check if this combination of short_name and alt_short_name exists + count, err := tx.NewSelect(). + Model((*Team)(nil)). + Where("short_name = ? AND alt_short_name = ?", shortname, altshortname). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.Select") + } + return count == 0, nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index ea2cd13..9c9c419 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -410,6 +410,9 @@ .h-1 { height: calc(var(--spacing) * 1); } + .h-3 { + height: calc(var(--spacing) * 3); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -443,6 +446,9 @@ .min-h-full { min-height: 100%; } + .w-3 { + width: calc(var(--spacing) * 3); + } .w-4 { width: calc(var(--spacing) * 4); } @@ -455,6 +461,9 @@ .w-12 { width: calc(var(--spacing) * 12); } + .w-20 { + width: calc(var(--spacing) * 20); + } .w-26 { width: calc(var(--spacing) * 26); } @@ -560,6 +569,9 @@ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } .grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); } @@ -612,6 +624,13 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-3 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -654,6 +673,11 @@ border-color: var(--surface2); } } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .overflow-hidden { overflow: hidden; } @@ -869,6 +893,9 @@ .py-8 { padding-block: calc(var(--spacing) * 8); } + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } .pt-5 { padding-top: calc(var(--spacing) * 5); } @@ -878,6 +905,9 @@ .pr-10 { padding-right: calc(var(--spacing) * 10); } + .pb-3 { + padding-bottom: calc(var(--spacing) * 3); + } .pb-4 { padding-bottom: calc(var(--spacing) * 4); } @@ -1205,6 +1235,13 @@ } } } + .hover\:text-blue { + &:hover { + @media (hover: hover) { + color: var(--blue); + } + } + } .hover\:text-green { &:hover { @media (hover: hover) { @@ -1301,6 +1338,14 @@ } } } + .disabled\:bg-green\/40 { + &:disabled { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 40%, transparent); + } + } + } .disabled\:bg-green\/60 { &:disabled { background-color: var(--green); @@ -1359,6 +1404,16 @@ height: calc(var(--spacing) * 10); } } + .sm\:w-1\/3 { + @media (width >= 40rem) { + width: calc(1/3 * 100%); + } + } + .sm\:w-2\/3 { + @media (width >= 40rem) { + width: calc(2/3 * 100%); + } + } .sm\:w-10 { @media (width >= 40rem) { width: calc(var(--spacing) * 10); diff --git a/internal/handlers/permtest.go b/internal/handlers/permtest.go deleted file mode 100644 index d020e91..0000000 --- a/internal/handlers/permtest.go +++ /dev/null @@ -1,25 +0,0 @@ -package handlers - -import ( - "net/http" - "strconv" - - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/oslstats/internal/db" - "git.haelnorr.com/h/oslstats/internal/roles" - "git.haelnorr.com/h/oslstats/internal/throw" - "github.com/uptrace/bun" -) - -func PermTester(s *hws.Server, conn *bun.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := db.CurrentUser(r.Context()) - tx, _ := conn.BeginTx(r.Context(), nil) - isAdmin, err := user.HasRole(r.Context(), tx, roles.Admin) - tx.Rollback() - if err != nil { - throw.InternalServiceError(s, w, r, "Error", err) - } - _, _ = w.Write([]byte(strconv.FormatBool(isAdmin))) - }) -} diff --git a/internal/handlers/season_detail.go b/internal/handlers/season_detail.go index 52dfc26..99bc049 100644 --- a/internal/handlers/season_detail.go +++ b/internal/handlers/season_detail.go @@ -19,12 +19,23 @@ func SeasonPage( return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seasonStr := r.PathValue("season_short_name") var season *db.Season + var leaguesWithTeams []db.LeagueWithTeams + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error season, err = db.GetSeason(ctx, tx, seasonStr) if err != nil { return false, errors.Wrap(err, "db.GetSeason") } + if season == nil { + return true, nil + } + + leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "season.MapTeamsToLeagues") + } + return true, nil }); !ok { return @@ -33,6 +44,6 @@ func SeasonPage( throw.NotFound(s, w, r, r.URL.Path) return } - renderSafely(seasonsview.DetailPage(season), s, r, w) + renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w) }) } diff --git a/internal/handlers/season_league_add_team.go b/internal/handlers/season_league_add_team.go new file mode 100644 index 0000000..2f57082 --- /dev/null +++ b/internal/handlers/season_league_add_team.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/auditlog" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/validation" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func SeasonLeagueAddTeam( + s *hws.Server, + conn *bun.DB, + audit *auditlog.Logger, +) 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 { + return + } + teamID := getter.Int("team_id").Required().Value + if ok := getter.ValidateAndNotify(s, w, r); !ok { + return + } + + var season *db.Season + var league *db.League + var team *db.Team + + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Get season + season, err = db.GetSeason(ctx, tx, seasonStr) + if err != nil { + return false, errors.Wrap(err, "db.GetSeason") + } + if season == nil { + notify.Warn(s, w, r, "Not Found", "Season not found.", nil) + return false, nil + } + + // Get league + league, err = db.GetLeague(ctx, tx, leagueStr) + if err != nil { + return false, errors.Wrap(err, "db.GetLeague") + } + if league == nil { + notify.Warn(s, w, r, "Not Found", "League not found.", nil) + return false, nil + } + + if !season.HasLeague(league.ID) { + notify.Warn(s, w, r, "Invalid League", "This league is not associated with this season.", nil) + return false, nil + } + + // Get team + team, err = db.GetTeam(ctx, tx, teamID) + if err != nil { + return false, errors.Wrap(err, "db.GetTeam") + } + if team == nil { + notify.Warn(s, w, r, "Not Found", "Team not found.", nil) + return false, nil + } + + // Check if team is already in this season (in any league) + var tpCount int + tpCount, err = tx.NewSelect(). + Model((*db.TeamParticipation)(nil)). + Where("season_id = ? AND team_id = ?", season.ID, team.ID). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.NewSelect") + } + if tpCount > 0 { + notify.Warn(s, w, r, "Already In Season", fmt.Sprintf( + "Team '%s' is already participating in this season.", + team.Name, + ), nil) + return false, nil + } + + // Add team to league + participation := &db.TeamParticipation{ + SeasonID: season.ID, + LeagueID: league.ID, + TeamID: team.ID, + } + + err = db.Insert(tx, participation).WithAudit(r, audit.Callback()).Exec(ctx) + if err != nil { + return false, errors.Wrap(err, "db.Insert") + } + + return true, nil + }); !ok { + return + } + + // Redirect to refresh the page + w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, league.ShortName)) + w.WriteHeader(http.StatusOK) + notify.Success(s, w, r, "Team Added", fmt.Sprintf( + "Successfully added '%s' to the league.", + team.Name, + ), nil) + }) +} diff --git a/internal/handlers/season_league_detail.go b/internal/handlers/season_league_detail.go new file mode 100644 index 0000000..0aa89cd --- /dev/null +++ b/internal/handlers/season_league_detail.go @@ -0,0 +1,53 @@ +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/throw" + seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func SeasonLeaguePage( + s *hws.Server, + conn *bun.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 teams []*db.Team + var allTeams []*db.Team + + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) + if err != nil { + return false, errors.Wrap(err, "db.GetSeasonLeague") + } + + // Get all teams for the dropdown (to add teams) + allTeams, err = db.GetList[db.Team](tx).GetAll(ctx) + if err != nil { + return false, errors.Wrap(err, "db.GetList[Team]") + } + + return true, nil + }); !ok { + return + } + + if season == nil || league == nil { + throw.NotFound(s, w, r, r.URL.Path) + return + } + + renderSafely(seasonsview.SeasonLeaguePage(season, league, teams, allTeams), s, r, w) + }) +} diff --git a/internal/handlers/team_shortnames_unique.go b/internal/handlers/team_shortnames_unique.go new file mode 100644 index 0000000..56f71b2 --- /dev/null +++ b/internal/handlers/team_shortnames_unique.go @@ -0,0 +1,57 @@ +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/validation" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// IsTeamShortNamesUnique checks if the combination of short_name and alt_short_name is unique +// and also validates that they are different from each other +func IsTeamShortNamesUnique( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getter, err := validation.ParseForm(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + shortName := getter.String("short_name").TrimSpace().ToUpper().MaxLength(3).Value + altShortName := getter.String("alt_short_name").TrimSpace().ToUpper().MaxLength(3).Value + + if shortName == "" || altShortName == "" { + w.WriteHeader(http.StatusOK) + return + } + + if shortName == altShortName { + w.WriteHeader(http.StatusConflict) + return + } + + var isUnique bool + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + isUnique, err = db.TeamShortNamesUnique(ctx, tx, shortName, altShortName) + if err != nil { + return false, errors.Wrap(err, "db.TeamShortNamesUnique") + } + return true, nil + }); !ok { + return + } + + if isUnique { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusConflict) + } + }) +} diff --git a/internal/handlers/teams_list.go b/internal/handlers/teams_list.go new file mode 100644 index 0000000..9fee626 --- /dev/null +++ b/internal/handlers/teams_list.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "context" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// TeamsPage renders the full page with the teams list, for use with GET requests +func TeamsPage( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pageOpts := pageOptsFromQuery(s, w, r) + if pageOpts == nil { + return + } + var teams *db.List[db.Team] + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + teams, err = db.ListTeams(ctx, tx, pageOpts) + if err != nil { + return false, errors.Wrap(err, "db.ListTeams") + } + return true, nil + }); !ok { + return + } + renderSafely(teamsview.ListPage(teams), s, r, w) + }) +} + +// TeamsList renders just the teams list, for use with POST requests and HTMX +func TeamsList( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pageOpts := pageOptsFromForm(s, w, r) + if pageOpts == nil { + return + } + var teams *db.List[db.Team] + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + teams, err = db.ListTeams(ctx, tx, pageOpts) + if err != nil { + return false, errors.Wrap(err, "db.ListTeams") + } + return true, nil + }); !ok { + return + } + renderSafely(teamsview.TeamsList(teams), s, r, w) + }) +} diff --git a/internal/handlers/teams_new.go b/internal/handlers/teams_new.go new file mode 100644 index 0000000..2580ca0 --- /dev/null +++ b/internal/handlers/teams_new.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "github.com/pkg/errors" + "github.com/uptrace/bun" + + "git.haelnorr.com/h/oslstats/internal/auditlog" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/notify" + "git.haelnorr.com/h/oslstats/internal/validation" + teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview" +) + +func NewTeamPage( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + renderSafely(teamsview.NewPage(), s, r, w) + }) +} + +func NewTeamSubmit( + s *hws.Server, + conn *bun.DB, + audit *auditlog.Logger, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + name := getter.String("name"). + TrimSpace().Required(). + MaxLength(25).MinLength(3).Value + shortname := getter.String("short_name"). + TrimSpace().Required(). + MaxLength(3).MinLength(3).Value + altShortname := getter.String("alt_short_name"). + TrimSpace().Required(). + MaxLength(3).MinLength(3).Value + color := getter.String("color"). + TrimSpace().MaxLength(7).Value + if !getter.ValidateAndNotify(s, w, r) { + return + } + + // Check that short names are different + if shortname == altShortname { + notify.Warn(s, w, r, "Invalid Short Names", "Short name and alternative short name must be different.", nil) + return + } + + nameUnique := false + shortNameComboUnique := false + var team *db.Team + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + nameUnique, err = db.IsUnique(ctx, tx, (*db.Team)(nil), "name", name) + if err != nil { + return false, errors.Wrap(err, "db.IsTeamNameUnique") + } + + shortNameComboUnique, err = db.TeamShortNamesUnique(ctx, tx, shortname, altShortname) + if err != nil { + return false, errors.Wrap(err, "db.TeamShortNamesUnique") + } + + if !nameUnique || !shortNameComboUnique { + return true, nil + } + team = &db.Team{ + Name: name, + ShortName: shortname, + AltShortName: altShortname, + Color: color, + } + err = db.Insert(tx, team).WithAudit(r, audit.Callback()).Exec(ctx) + if err != nil { + return false, errors.Wrap(err, "db.Insert") + } + return true, nil + }); !ok { + return + } + + if !nameUnique { + notify.Warn(s, w, r, "Duplicate Name", "This team name is already taken.", nil) + return + } + + if !shortNameComboUnique { + notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil) + return + } + w.Header().Set("HX-Redirect", "/teams") + w.WriteHeader(http.StatusOK) + notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil) + }) +} diff --git a/internal/permissions/constants.go b/internal/permissions/constants.go index e649c21..b54db87 100644 --- a/internal/permissions/constants.go +++ b/internal/permissions/constants.go @@ -23,6 +23,12 @@ const ( LeaguesUpdate Permission = "leagues.update" LeaguesDelete Permission = "leagues.delete" + // Teams permissions + TeamsCreate Permission = "teams.create" + TeamsUpdate Permission = "teams.update" + TeamsDelete Permission = "teams.delete" + TeamsAddToLeague Permission = "teams.add_to_league" + // Users permissions UsersUpdate Permission = "users.update" UsersBan Permission = "users.ban" diff --git a/internal/view/baseview/navbar.templ b/internal/view/baseview/navbar.templ index 59958a7..e841ab4 100644 --- a/internal/view/baseview/navbar.templ +++ b/internal/view/baseview/navbar.templ @@ -21,6 +21,7 @@ func getNavItems() []NavItem { return []NavItem{ {Name: "Seasons", Href: "/seasons"}, {Name: "Leagues", Href: "/leagues"}, + {Name: "Teams", Href: "/teams"}, } } @@ -30,7 +31,7 @@ func getProfileItems(ctx context.Context) []ProfileItem { {Name: "Profile", Href: "/profile"}, {Name: "Account", Href: "/account"}, } - + cache := contexts.Permissions(ctx) if cache != nil && cache.Roles["admin"] { items = append(items, ProfileItem{ @@ -38,7 +39,7 @@ func getProfileItems(ctx context.Context) []ProfileItem { Href: "/admin", }) } - + return items } diff --git a/internal/view/leaguesview/new_form.templ b/internal/view/leaguesview/new_form.templ index 261fb18..0d21211 100644 --- a/internal/view/leaguesview/new_form.templ +++ b/internal/view/leaguesview/new_form.templ @@ -6,7 +6,7 @@ templ NewForm() { hx-swap="none" x-data={ templ.JSFuncCall("newLeagueFormData").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) { isSubmitting=false; buttonText='Create League'; generalError='An error occurred. Please try again.'; }" + @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 League'; generalError='An error occurred. Please try again.'; }" > + +} diff --git a/internal/view/teamsview/new_form.templ b/internal/view/teamsview/new_form.templ new file mode 100644 index 0000000..9d3a690 --- /dev/null +++ b/internal/view/teamsview/new_form.templ @@ -0,0 +1,216 @@ +package teamsview + +templ NewForm() { +
+} diff --git a/internal/view/teamsview/new_page.templ b/internal/view/teamsview/new_page.templ new file mode 100644 index 0000000..c2105cd --- /dev/null +++ b/internal/view/teamsview/new_page.templ @@ -0,0 +1,23 @@ +package teamsview + +import "git.haelnorr.com/h/oslstats/internal/view/baseview" + +templ NewPage() { + @baseview.Layout("New Team") { ++ Add a new team to the system. All fields except color are required. +
+