Compare commits

..

2 Commits

Author SHA1 Message Date
0c5a88c309 we have fixtures ladies and gentleman 2026-02-15 19:56:03 +11:00
ef8c022e60 everybody loves a refactor 2026-02-15 12:27:36 +11:00
58 changed files with 1620 additions and 299 deletions

View File

@@ -20,10 +20,10 @@ func NewAudit(r *http.Request, u *User) *AuditMeta {
// AuditInfo contains metadata for audit logging // AuditInfo contains metadata for audit logging
type AuditInfo struct { type AuditInfo struct {
Action string // e.g., "seasons.create", "users.update" Action string // e.g., "seasons.create", "users.update"
ResourceType string // e.g., "season", "user" ResourceType string // e.g., "season", "user"
ResourceID any // Primary key value (int, string, etc.) ResourceID any // Primary key value (int, string, etc.)
Details map[string]any // Changed fields or additional metadata Details any // Changed fields or additional metadata
} }
// extractTableName gets the bun table name from a model type using reflection // extractTableName gets the bun table name from a model type using reflection

View File

@@ -3,6 +3,7 @@ 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"
@@ -77,6 +78,7 @@ 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)
} }

View File

@@ -2,7 +2,6 @@ package db
import ( import (
"context" "context"
"database/sql"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -46,13 +45,18 @@ func (d *deleter[T]) WithAudit(meta *AuditMeta, info *AuditInfo) *deleter[T] {
} }
func (d *deleter[T]) Delete(ctx context.Context) error { func (d *deleter[T]) Delete(ctx context.Context) error {
_, err := d.q.Exec(ctx) result, err := d.q.Exec(ctx)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return errors.Wrap(err, "bun.DeleteQuery.Exec") return errors.Wrap(err, "bun.DeleteQuery.Exec")
} }
rows, err := result.RowsAffected()
if err != nil {
return errors.Wrap(err, "result.RowsAffected")
}
if rows == 0 {
resource := extractResourceType(extractTableName[T]())
return BadRequestNotFound(resource, "id", d.resourceID)
}
// Handle audit logging if enabled // Handle audit logging if enabled
if d.audit != nil { if d.audit != nil {
@@ -88,9 +92,6 @@ func DeleteWithProtection[T systemType](ctx context.Context, tx bun.Tx, id int,
if err != nil { if err != nil {
return errors.Wrap(err, "GetByID") return errors.Wrap(err, "GetByID")
} }
if item == nil {
return errors.New("record not found")
}
if (*item).isSystem() { if (*item).isSystem() {
return errors.New("record is system protected") return errors.New("record is system protected")
} }

View File

@@ -51,11 +51,11 @@ func (u *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord
func (u *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToken, error) { func (u *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
token, err := u.GetDiscordToken(ctx, tx) token, err := u.GetDiscordToken(ctx, tx)
if err != nil { if err != nil {
if IsBadRequest(err) {
return nil, nil // Token doesn't exist - not an error
}
return nil, errors.Wrap(err, "user.GetDiscordToken") return nil, errors.Wrap(err, "user.GetDiscordToken")
} }
if token == nil {
return nil, nil
}
_, err = tx.NewDelete(). _, err = tx.NewDelete().
Model((*DiscordToken)(nil)). Model((*DiscordToken)(nil)).
Where("discord_id = ?", u.DiscordID). Where("discord_id = ?", u.DiscordID).

31
internal/db/errors.go Normal file
View File

@@ -0,0 +1,31 @@
package db
import (
"fmt"
"strings"
)
func IsBadRequest(err error) bool {
return strings.Contains(err.Error(), "bad request:")
}
func BadRequest(err string) error {
return fmt.Errorf("bad request: %s", err)
}
func BadRequestNotFound(resource, field string, value any) error {
errStr := fmt.Sprintf("%s with %s=%v not found", resource, field, value)
return BadRequest(errStr)
}
func BadRequestNotAssociated(parent, child, parentField, childField string, parentID, childID any) error {
errStr := fmt.Sprintf("%s with %s=%v not associated to %s with %s=%v",
child, childField, childID, parent, parentField, parentID)
return BadRequest(errStr)
}
func BadRequestAssociated(parent, child, parentField, childField string, parentID, childID any) error {
errStr := fmt.Sprintf("%s with %s=%v already associated to %s with %s=%v",
child, childField, childID, parent, parentField, parentID)
return BadRequest(errStr)
}

282
internal/db/fixture.go Normal file
View File

@@ -0,0 +1,282 @@
package db
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Fixture struct {
bun.BaseModel `bun:"table:fixtures,alias:f"`
ID int `bun:"id,pk,autoincrement"`
SeasonID int `bun:",notnull,unique:round"`
LeagueID int `bun:",notnull,unique:round"`
HomeTeamID int `bun:",notnull,unique:round"`
AwayTeamID int `bun:",notnull,unique:round"`
Round int `bun:"round,unique:round"`
GameWeek *int `bun:"game_week"`
CreatedAt int64 `bun:"created_at,notnull"`
UpdatedAt *int64 `bun:"updated_at"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"`
}
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
homeTeamID, awayTeamID, round int, audit *AuditMeta,
) (*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
homeTeam, err := GetTeam(ctx, tx, homeTeamID)
if err != nil {
return nil, errors.Wrap(err, "GetTeam")
}
awayTeam, err := GetTeam(ctx, tx, awayTeamID)
if err != nil {
return nil, errors.Wrap(err, "GetTeam")
}
if err = checkTeamsAssociated(season, league, teams, []*Team{homeTeam, awayTeam}); err != nil {
return nil, errors.Wrap(err, "checkTeamsAssociated")
}
fixture := newFixture(season, league, homeTeam, awayTeam, round, time.Now())
err = Insert(tx, fixture).WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return fixture, nil
}
func NewRound(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
round int, audit *AuditMeta,
) ([]*Fixture, error) {
season, league, teams, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
fixtures := generateRound(season, league, round, teams)
err = InsertMultiple(tx, fixtures).WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "InsertMultiple")
}
return fixtures, nil
}
func GetFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Fixture, error) {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeasonLeague")
}
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", season.ID).
Where("league_id = ?", league.ID).
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetList")
}
return season, league, fixtures, nil
}
func GetFixture(ctx context.Context, tx bun.Tx, id int) (*Fixture, error) {
return GetByID[Fixture](tx, id).
Relation("Season").
Relation("League").
Relation("HomeTeam").
Relation("AwayTeam").
Get(ctx)
}
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week = ?", gameweek).
Order("round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func GetUnallocatedFixtures(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 NULL").
Order("round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func CountUnallocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) {
count, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NULL").
Count(ctx)
if err != nil {
return 0, errors.Wrap(err, "GetList")
}
return count, nil
}
func GetMaxGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (int, error) {
var maxGameWeek int
err := tx.NewSelect().
Model((*Fixture)(nil)).
Column("game_week").
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Order("game_week DESC NULLS LAST").
Limit(1).Scan(ctx, &maxGameWeek)
if err != nil {
return 0, errors.Wrap(err, "tx.NewSelect")
}
return maxGameWeek, nil
}
func UpdateFixtureGameWeeks(ctx context.Context, tx bun.Tx, fixtures []*Fixture, audit *AuditMeta) error {
details := []any{}
for _, fixture := range fixtures {
err := UpdateByID(tx, fixture.ID, fixture).
Column("game_week").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
details = append(details, map[string]any{"fixture_id": fixture.ID, "game_week": fixture.GameWeek})
}
info := &AuditInfo{
"fixtures.manage",
"fixture",
"multiple",
map[string]any{"updated": details},
}
err := LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
func DeleteAllFixtures(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
season, league, _, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return errors.Wrap(err, "GetSeasonLeague")
}
err = DeleteItem[Fixture](tx).
Where("season_id = ?", season.ID).
Where("league_id = ?", league.ID).
WithAudit(audit, nil).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteItem")
}
return nil
}
func DeleteFixture(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error {
err := DeleteByID[Fixture](tx, id).
WithAudit(audit, nil).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID")
}
return nil
}
func newFixture(season *Season, league *League, homeTeam, awayTeam *Team, round int, created time.Time) *Fixture {
return &Fixture{
SeasonID: season.ID,
LeagueID: league.ID,
HomeTeamID: homeTeam.ID,
AwayTeamID: awayTeam.ID,
Round: round,
CreatedAt: created.Unix(),
}
}
func checkTeamsAssociated(season *Season, league *League, teamsIn []*Team, toCheck []*Team) error {
badIDs := []string{}
master := map[int]bool{}
for _, team := range teamsIn {
master[team.ID] = true
}
for _, team := range toCheck {
if !master[team.ID] {
badIDs = append(badIDs, strconv.Itoa(team.ID))
}
}
ids := strings.Join(badIDs, ",")
if len(ids) > 0 {
return BadRequestNotAssociated("season_league", "team",
"season_id,league_id", "ids",
fmt.Sprintf("%v,%v", season.ID, league.ID),
ids)
}
return nil
}
type versus struct {
homeTeam *Team
awayTeam *Team
}
func generateRound(season *Season, league *League, round int, teams []*Team) []*Fixture {
now := time.Now()
numTeams := len(teams)
numGames := numTeams * (numTeams - 1) / 2
fixtures := make([]*Fixture, numGames)
for i, matchup := range allTeamsPlay(teams, round) {
fixtures[i] = newFixture(season, league, matchup.homeTeam, matchup.awayTeam, round, now)
}
return fixtures
}
func allTeamsPlay(teams []*Team, round int) []*versus {
matchups := []*versus{}
if len(teams) < 2 {
return matchups
}
team1 := teams[0]
teams = teams[1:]
matchups = append(matchups, playOtherTeams(team1, teams, round)...)
matchups = append(matchups, allTeamsPlay(teams, round)...)
return matchups
}
func playOtherTeams(team *Team, teams []*Team, round int) []*versus {
matchups := make([]*versus, len(teams))
for i, opponent := range teams {
versus := &versus{}
if i%2+round%2 == 0 {
versus.homeTeam = team
versus.awayTeam = opponent
} else {
versus.homeTeam = opponent
versus.awayTeam = team
}
matchups[i] = versus
}
return matchups
}

View File

@@ -24,7 +24,8 @@ func (g *fieldgetter[T]) get(ctx context.Context) (*T, error) {
Scan(ctx) Scan(ctx)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil resource := extractResourceType(extractTableName[T]())
return nil, BadRequestNotFound(resource, g.field, g.value)
} }
return nil, errors.Wrap(err, "bun.SelectQuery.Scan") return nil, errors.Wrap(err, "bun.SelectQuery.Scan")
} }

View File

@@ -3,6 +3,7 @@ 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"
@@ -91,6 +92,11 @@ func (l *listgetter[T]) Where(query string, args ...any) *listgetter[T] {
return l return l
} }
func (l *listgetter[T]) Order(orders ...string) *listgetter[T] {
l.q = l.q.Order(orders...)
return l
}
func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *listgetter[T] { func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *listgetter[T] {
l.q = l.q.Relation(name, apply...) l.q = l.q.Relation(name, apply...)
return l return l
@@ -104,6 +110,7 @@ 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
} }
@@ -128,6 +135,14 @@ func (l *listgetter[T]) GetPaged(ctx context.Context, pageOpts, defaults *PageOp
return list, nil return list, nil
} }
func (l *listgetter[T]) Count(ctx context.Context) (int, error) {
count, err := l.q.Count(ctx)
if err != nil {
return 0, errors.Wrap(err, "query.Count")
}
return count, nil
}
func (l *listgetter[T]) GetAll(ctx context.Context) ([]*T, error) { func (l *listgetter[T]) GetAll(ctx context.Context) ([]*T, error) {
err := l.q.Scan(ctx) err := l.q.Scan(ctx)
if err != nil && errors.Is(err, sql.ErrNoRows) { if err != nil && errors.Is(err, sql.ErrNoRows) {

View File

@@ -109,6 +109,7 @@ func (i *inserter[T]) Exec(ctx context.Context) error {
} }
} else { } else {
i.auditInfo.ResourceID = extractPrimaryKey(i.model) i.auditInfo.ResourceID = extractPrimaryKey(i.model)
i.auditInfo.Details = i.model
} }
} }

View File

@@ -10,13 +10,13 @@ import (
type League struct { type League struct {
bun.BaseModel `bun:"table:leagues,alias:l"` bun.BaseModel `bun:"table:leagues,alias:l"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull"` ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
Description string `bun:"description"` Description string `bun:"description" json:"description"`
Seasons []Season `bun:"m2m:season_leagues,join:League=Season"` Seasons []Season `bun:"m2m:season_leagues,join:League=Season" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:League=Team"` Teams []Team `bun:"m2m:team_participations,join:League=Team" json:"-"`
} }
func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) { func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) {

View File

@@ -256,7 +256,7 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error {
// validateMigrations ensures migrations compile before running // validateMigrations ensures migrations compile before running
func validateMigrations(ctx context.Context) error { func validateMigrations(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "go", "build", cmd := exec.CommandContext(ctx, "go", "build",
"-o", "/dev/null", "./cmd/oslstats/migrations") "-o", "/dev/null", "./internal/db/migrations")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {

View File

@@ -31,12 +31,12 @@ func init() {
Model(&permissionsData). Model(&permissionsData).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "dbConn.NewInsert") return errors.Wrap(err, "conn.NewInsert")
} }
return nil return nil
}, },
// DOWN migration // DOWN migration
func(ctx context.Context, dbConn *bun.DB) error { func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here // Add your rollback code here
return nil return nil
}, },

View File

@@ -0,0 +1,52 @@
package migrations
import (
"context"
"time"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/pkg/errors"
"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.Fixture)(nil)).
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
now := time.Now().Unix()
permissionsData := []*db.Permission{
{Name: "fixtures.create", DisplayName: "Create Fixtures", Description: "Create new fixtures", Resource: "fixtures", Action: "create", IsSystem: true, CreatedAt: now},
{Name: "fixtures.manage", DisplayName: "Manage Fixtures", Description: "Manage fixtures", Resource: "fixtures", Action: "manage", IsSystem: true, CreatedAt: now},
{Name: "fixtures.delete", DisplayName: "Delete Fixtures", Description: "Delete fixtures", Resource: "fixtures", Action: "delete", IsSystem: true, CreatedAt: now},
}
_, err = conn.NewInsert().
Model(&permissionsData).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "conn.NewInsert")
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
_, err := conn.NewDropTable().
Model((*db.Fixture)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
)
}

View File

@@ -28,7 +28,7 @@ func (p Permission) isSystem() bool {
} }
// GetPermissionByName queries the database for a permission matching the given name // GetPermissionByName queries the database for a permission matching the given name
// Returns nil, nil if no permission is found // Returns a BadRequestNotFound error if no permission is found
func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permission) (*Permission, error) { func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permission) (*Permission, error) {
if name == "" { if name == "" {
return nil, errors.New("name cannot be empty") return nil, errors.New("name cannot be empty")
@@ -37,7 +37,7 @@ func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permis
} }
// GetPermissionByID queries the database for a permission matching the given ID // GetPermissionByID queries the database for a permission matching the given ID
// Returns nil, nil if no permission is found // Returns a BadRequestNotFound error if no permission is found
func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, error) { func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, error) {
if id <= 0 { if id <= 0 {
return nil, errors.New("id must be positive") return nil, errors.New("id must be positive")

View File

@@ -30,7 +30,7 @@ func (r Role) isSystem() bool {
} }
// GetRoleByName queries the database for a role matching the given name // GetRoleByName queries the database for a role matching the given name
// Returns nil, nil if no role is found // Returns a BadRequestNotFound error if no role is found
func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, error) { func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, error) {
if name == "" { if name == "" {
return nil, errors.New("name cannot be empty") return nil, errors.New("name cannot be empty")
@@ -39,7 +39,7 @@ func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, erro
} }
// GetRoleByID queries the database for a role matching the given ID // GetRoleByID queries the database for a role matching the given ID
// Returns nil, nil if no role is found // Returns a BadRequestNotFound error if no role is found
func GetRoleByID(ctx context.Context, tx bun.Tx, id int) (*Role, error) { func GetRoleByID(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
return GetByID[Role](tx, id).Relation("Permissions").Get(ctx) return GetByID[Role](tx, id).Relation("Permissions").Get(ctx)
} }
@@ -110,9 +110,6 @@ func DeleteRole(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error
if err != nil { if err != nil {
return errors.Wrap(err, "GetRoleByID") return errors.Wrap(err, "GetRoleByID")
} }
if role == nil {
return errors.New("role not found")
}
if role.IsSystem { if role.IsSystem {
return errors.New("cannot delete system roles") return errors.New("cannot delete system roles")
} }

View File

@@ -28,17 +28,17 @@ const (
type Season struct { type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"` bun.BaseModel `bun:"table:seasons,alias:s"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull"` ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
StartDate time.Time `bun:"start_date,notnull"` StartDate time.Time `bun:"start_date,notnull" json:"start_date"`
EndDate bun.NullTime `bun:"end_date"` EndDate bun.NullTime `bun:"end_date" json:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
SlapVersion string `bun:"slap_version,notnull,default:'rebound'"` SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League"` Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team"` Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
} }
// NewSeason creats a new season // NewSeason creats a new season
@@ -163,11 +163,21 @@ func (s *Season) GetDefaultTab() string {
} }
} }
func (s *Season) HasLeague(leagueID int) bool { func (s *Season) HasLeague(league *League) bool {
for _, league := range s.Leagues { for _, league_ := range s.Leagues {
if league.ID == leagueID { if league_.ID == league.ID {
return true return true
} }
} }
return false return false
} }
func (s *Season) GetLeague(leagueShortName string) (*League, error) {
for _, league := range s.Leagues {
if league.ShortName == leagueShortName {
return &league, nil
}
}
return nil, BadRequestNotAssociated("season", "league",
"id", "short_name", s.ID, leagueShortName)
}

View File

@@ -24,19 +24,14 @@ func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
return nil, nil, nil, errors.New("league short_name cannot be empty") return nil, nil, nil, errors.New("league short_name cannot be empty")
} }
// Get the season
season, err := GetSeason(ctx, tx, seasonShortName) season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason") return nil, nil, nil, errors.Wrap(err, "GetSeason")
} }
// Get the league league, err := season.GetLeague(leagueShortName)
league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague") return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
if season == nil || league == nil || !season.HasLeague(league.ID) {
return nil, nil, nil, nil
} }
// Get all teams participating in this season+league // Get all teams participating in this season+league
@@ -59,18 +54,13 @@ func NewSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
if err != nil { if err != nil {
return errors.Wrap(err, "GetSeason") return errors.Wrap(err, "GetSeason")
} }
if season == nil {
return errors.New("season not found")
}
league, err := GetLeague(ctx, tx, leagueShortName) league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil { if err != nil {
return errors.Wrap(err, "GetLeague") return errors.Wrap(err, "GetLeague")
} }
if league == nil { if season.HasLeague(league) {
return errors.New("league not found") return BadRequestAssociated("season", "league",
} "id", "id", season.ID, league.ID)
if season.HasLeague(league.ID) {
return errors.New("league already added to season")
} }
seasonLeague := &SeasonLeague{ seasonLeague := &SeasonLeague{
SeasonID: season.ID, SeasonID: season.ID,
@@ -90,15 +80,9 @@ func NewSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
} }
func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName string, audit *AuditMeta) error { func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName string, audit *AuditMeta) error {
league, err := GetLeague(ctx, tx, leagueShortName) league, err := s.GetLeague(leagueShortName)
if err != nil { if err != nil {
return errors.Wrap(err, "GetLeague") return errors.Wrap(err, "s.GetLeague")
}
if league == nil {
return errors.New("league not found")
}
if !s.HasLeague(league.ID) {
return errors.New("league not in season")
} }
info := &AuditInfo{ info := &AuditInfo{
string(permissions.SeasonsRemoveLeague), string(permissions.SeasonsRemoveLeague),
@@ -116,3 +100,12 @@ func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName st
} }
return nil return nil
} }
func (t *Team) InTeams(teams []*Team) bool {
for _, team := range teams {
if t.ID == team.ID {
return true
}
}
return false
}

View File

@@ -32,6 +32,7 @@ func (db *DB) RegisterModels() []any {
(*Role)(nil), (*Role)(nil),
(*Permission)(nil), (*Permission)(nil),
(*AuditLog)(nil), (*AuditLog)(nil),
(*Fixture)(nil),
} }
db.RegisterModel(models...) db.RegisterModel(models...)
return models return models

View File

@@ -9,14 +9,14 @@ import (
type Team struct { type Team struct {
bun.BaseModel `bun:"table:teams,alias:t"` bun.BaseModel `bun:"table:teams,alias:t"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,notnull,unique:short_names"` ShortName string `bun:"short_name,notnull,unique:short_names" json:"short_name"`
AltShortName string `bun:"alt_short_name,notnull,unique:short_names"` AltShortName string `bun:"alt_short_name,notnull,unique:short_names" json:"alt_short_name"`
Color string `bun:"color"` Color string `bun:"color" json:"color,omitempty"`
Seasons []Season `bun:"m2m:team_participations,join:Team=Season"` Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
Leagues []League `bun:"m2m:team_participations,join:Team=League"` Leagues []League `bun:"m2m:team_participations,join:Team=League" 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) {

View File

@@ -23,28 +23,17 @@ func NewTeamParticipation(ctx context.Context, tx bun.Tx,
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason") return nil, nil, nil, errors.Wrap(err, "GetSeason")
} }
if season == nil { league, err := season.GetLeague(leagueShortName)
return nil, nil, nil, errors.New("season not found")
}
league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague") return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
if league == nil {
return nil, nil, nil, errors.New("league not found")
}
if !season.HasLeague(league.ID) {
return nil, nil, nil, errors.New("league is not assigned to the season")
} }
team, err := GetTeam(ctx, tx, teamID) team, err := GetTeam(ctx, tx, teamID)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetTeam") return nil, nil, nil, errors.Wrap(err, "GetTeam")
} }
if team == nil {
return nil, nil, nil, errors.New("team not found")
}
if team.InSeason(season.ID) { if team.InSeason(season.ID) {
return nil, nil, nil, errors.New("team already in season") return nil, nil, nil, BadRequestAssociated("season", "team",
"id", "id", season.ID, team.ID)
} }
participation := &TeamParticipation{ participation := &TeamParticipation{
SeasonID: season.ID, SeasonID: season.ID,

View File

@@ -85,10 +85,18 @@ func (u *updater[T]) Exec(ctx context.Context) error {
} }
// Execute update // Execute update
_, err := u.q.Exec(ctx) result, err := u.q.Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "bun.UpdateQuery.Exec") return errors.Wrap(err, "bun.UpdateQuery.Exec")
} }
rows, err := result.RowsAffected()
if err != nil {
return errors.Wrap(err, "result.RowsAffected")
}
if rows == 0 {
resource := extractResourceType(extractTableName[T]())
return BadRequestNotFound(resource, "id", extractPrimaryKey(u.model))
}
// Handle audit logging if enabled // Handle audit logging if enabled
if u.audit != nil { if u.audit != nil {

View File

@@ -15,12 +15,12 @@ import (
type User struct { type User struct {
bun.BaseModel `bun:"table:users,alias:u"` bun.BaseModel `bun:"table:users,alias:u"`
ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key) ID int `bun:"id,pk,autoincrement" json:"id"`
Username string `bun:"username,unique"` // Username (unique) Username string `bun:"username,unique" json:"username"`
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database CreatedAt int64 `bun:"created_at" json:"created_at"`
DiscordID string `bun:"discord_id,unique"` DiscordID string `bun:"discord_id,unique" json:"discord_id"`
Roles []*Role `bun:"m2m:user_roles,join:User=Role"` Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"`
} }
func (u *User) GetID() int { func (u *User) GetID() int {
@@ -53,13 +53,13 @@ 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 nil, nil 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).Get(ctx)
} }
// GetUserByUsername queries the database for a user matching the given username // GetUserByUsername queries the database for a user matching the given username
// Returns nil, nil if no user is found // Returns a BadRequestNotFound error if no user is found
func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) { func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) {
if username == "" { if username == "" {
return nil, errors.New("username not provided") return nil, errors.New("username not provided")
@@ -68,7 +68,7 @@ func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User,
} }
// GetUserByDiscordID queries the database for a user matching the given discord id // GetUserByDiscordID queries the database for a user matching the given discord id
// Returns nil, nil if no user is found // Returns a BadRequestNotFound error if no user is found
func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User, error) { func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User, error) {
if discordID == "" { if discordID == "" {
return nil, errors.New("discord_id not provided") return nil, errors.New("discord_id not provided")

View File

@@ -94,9 +94,6 @@ func HasRole(ctx context.Context, tx bun.Tx, userID int, roleName roles.Role) (b
if err != nil { if err != nil {
return false, errors.Wrap(err, "GetByID") return false, errors.Wrap(err, "GetByID")
} }
if user == nil {
return false, nil
}
for _, role := range user.Roles { for _, role := range user.Roles {
if role.Name == roleName { if role.Name == roleName {
return true, nil return true, nil

View File

@@ -383,6 +383,9 @@
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
.ml-auto {
margin-left: auto;
}
.line-clamp-2 { .line-clamp-2 {
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
@@ -453,6 +456,15 @@
.max-h-\[90vh\] { .max-h-\[90vh\] {
max-height: 90vh; max-height: 90vh;
} }
.max-h-\[600px\] {
max-height: 600px;
}
.min-h-48 {
min-height: calc(var(--spacing) * 48);
}
.min-h-96 {
min-height: calc(var(--spacing) * 96);
}
.min-h-\[calc\(100vh-200px\)\] { .min-h-\[calc\(100vh-200px\)\] {
min-height: calc(100vh - 200px); min-height: calc(100vh - 200px);
} }
@@ -477,6 +489,9 @@
.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);
} }
@@ -570,6 +585,9 @@
.animate-spin { .animate-spin {
animation: var(--animate-spin); animation: var(--animate-spin);
} }
.cursor-grab {
cursor: grab;
}
.cursor-not-allowed { .cursor-not-allowed {
cursor: not-allowed; cursor: not-allowed;
} }
@@ -776,9 +794,19 @@
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px; border-bottom-width: 2px;
} }
.border-dashed {
--tw-border-style: dashed;
border-style: dashed;
}
.border-blue { .border-blue {
border-color: var(--blue); border-color: var(--blue);
} }
.border-blue\/50 {
border-color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--blue) 50%, transparent);
}
}
.border-green { .border-green {
border-color: var(--green); border-color: var(--green);
} }
@@ -896,6 +924,12 @@
.bg-surface1 { .bg-surface1 {
background-color: var(--surface1); background-color: var(--surface1);
} }
.bg-surface1\/30 {
background-color: var(--surface1);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--surface1) 30%, transparent);
}
}
.bg-surface2 { .bg-surface2 {
background-color: var(--surface2); background-color: var(--surface2);
} }
@@ -1311,6 +1345,16 @@
} }
} }
} }
.hover\:bg-red\/80 {
&:hover {
@media (hover: hover) {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 80%, transparent);
}
}
}
}
.hover\:bg-sapphire\/75 { .hover\:bg-sapphire\/75 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1383,6 +1427,16 @@
} }
} }
} }
.hover\:text-blue\/80 {
&:hover {
@media (hover: hover) {
color: var(--blue);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--blue) 80%, transparent);
}
}
}
}
.hover\:text-green { .hover\:text-green {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1400,6 +1454,13 @@
} }
} }
} }
.hover\:text-red {
&:hover {
@media (hover: hover) {
color: var(--red);
}
}
}
.hover\:text-red\/75 { .hover\:text-red\/75 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1410,6 +1471,16 @@
} }
} }
} }
.hover\:text-red\/80 {
&:hover {
@media (hover: hover) {
color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--red) 80%, transparent);
}
}
}
}
.hover\:text-subtext1 { .hover\:text-subtext1 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1461,6 +1532,11 @@
outline-style: none; outline-style: none;
} }
} }
.active\:cursor-grabbing {
&:active {
cursor: grabbing;
}
}
.disabled\:pointer-events-none { .disabled\:pointer-events-none {
&:disabled { &:disabled {
pointer-events: none; pointer-events: none;
@@ -1688,6 +1764,11 @@
inset-inline-end: calc(var(--spacing) * 8); inset-inline-end: calc(var(--spacing) * 8);
} }
} }
.lg\:col-span-2 {
@media (width >= 64rem) {
grid-column: span 2 / span 2;
}
}
.lg\:col-span-3 { .lg\:col-span-3 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-column: span 3 / span 3; grid-column: span 3 / span 3;

View File

@@ -185,12 +185,12 @@ func AdminAuditLogDetail(s *hws.Server, conn *db.DB) http.Handler {
var err error var err error
log, err = db.GetAuditLogByID(ctx, tx, id) log, err = db.GetAuditLogByID(ctx, tx, id)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetAuditLogByID") return false, errors.Wrap(err, "db.GetAuditLogByID")
} }
if log == nil {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return true, nil return true, nil
}); !ok { }); !ok {
return return

View File

@@ -31,12 +31,12 @@ func AdminPreviewRoleStart(s *hws.Server, conn *db.DB, ssl bool) http.Handler {
var err error var err error
role, err = db.GetRoleByID(ctx, tx, roleID) role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, "Role not found")
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
if role == nil {
throw.NotFound(s, w, r, "Role not found")
return false, nil
}
// Cannot preview admin role // Cannot preview admin role
if role.Name == roles.Admin { if role.Name == roles.Admin {
throw.BadRequest(s, w, r, "Cannot preview admin role", nil) throw.BadRequest(s, w, r, "Cannot preview admin role", nil)

View File

@@ -9,6 +9,7 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/roles" "git.haelnorr.com/h/oslstats/internal/roles"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
adminview "git.haelnorr.com/h/oslstats/internal/view/adminview" adminview "git.haelnorr.com/h/oslstats/internal/view/adminview"
@@ -108,7 +109,7 @@ func AdminRoleManage(s *hws.Server, conn *db.DB) http.Handler {
roleIDStr := r.PathValue("id") roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr) roleID, err := strconv.Atoi(roleIDStr)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
@@ -117,11 +118,12 @@ func AdminRoleManage(s *hws.Server, conn *db.DB) http.Handler {
var err error var err error
role, err = db.GetRoleByID(ctx, tx, roleID) role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
if role == nil {
return false, errors.New("role not found")
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
@@ -146,11 +148,12 @@ func AdminRoleDeleteConfirm(s *hws.Server, conn *db.DB) http.Handler {
var err error var err error
role, err = db.GetRoleByID(ctx, tx, roleID) role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
if role == nil {
return false, errors.New("role not found")
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
@@ -166,7 +169,7 @@ func AdminRoleDelete(s *hws.Server, conn *db.DB) http.Handler {
roleIDStr := r.PathValue("id") roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr) roleID, err := strconv.Atoi(roleIDStr)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
@@ -180,11 +183,12 @@ func AdminRoleDelete(s *hws.Server, conn *db.DB) http.Handler {
// First check if role exists and get its details // First check if role exists and get its details
role, err := db.GetRoleByID(ctx, tx, roleID) role, err := db.GetRoleByID(ctx, tx, roleID)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
if role == nil {
return false, errors.New("role not found")
}
// Check if it's a system role // Check if it's a system role
if role.IsSystem { if role.IsSystem {
@@ -194,6 +198,10 @@ 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.NewAudit(r, nil))
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.DeleteRole") return false, errors.Wrap(err, "db.DeleteRole")
} }
@@ -218,7 +226,7 @@ func AdminRolePermissionsModal(s *hws.Server, conn *db.DB) http.Handler {
roleIDStr := r.PathValue("id") roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr) roleID, err := strconv.Atoi(roleIDStr)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
@@ -232,11 +240,12 @@ func AdminRolePermissionsModal(s *hws.Server, conn *db.DB) http.Handler {
var err error var err error
role, err = db.GetRoleByID(ctx, tx, roleID) role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
if role == nil {
return false, errors.New("role not found")
}
// Load all permissions // Load all permissions
allPermissions, err = db.ListAllPermissions(ctx, tx) allPermissions, err = db.ListAllPermissions(ctx, tx)
@@ -281,7 +290,7 @@ func AdminRolePermissionsUpdate(s *hws.Server, conn *db.DB) http.Handler {
roleIDStr := r.PathValue("id") roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr) roleID, err := strconv.Atoi(roleIDStr)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
@@ -305,12 +314,12 @@ func AdminRolePermissionsUpdate(s *hws.Server, conn *db.DB) http.Handler {
if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
role, err := db.GetRoleByID(ctx, tx, roleID) role, err := db.GetRoleByID(ctx, tx, roleID)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID") return false, errors.Wrap(err, "db.GetRoleByID")
} }
if role == nil {
w.WriteHeader(http.StatusBadRequest)
return false, nil
}
err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAudit(r, nil)) err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAudit(r, nil))
if err != nil { if err != nil {
return false, errors.Wrap(err, "role.UpdatePermissions") return false, errors.Wrap(err, "role.UpdatePermissions")

View File

@@ -42,9 +42,6 @@ func ensureUserHasAdminRole(ctx context.Context, tx bun.Tx, user *db.User) error
if err != nil { if err != nil {
return errors.Wrap(err, "db.GetRoleByName") return errors.Wrap(err, "db.GetRoleByName")
} }
if adminRole == nil {
return errors.New("admin role not found in database")
}
// Grant admin role // Grant admin role
err = db.AssignRole(ctx, tx, user.ID, adminRole.ID, nil) err = db.AssignRole(ctx, tx, user.ID, adminRole.ID, nil)

View File

@@ -158,7 +158,7 @@ func login(
} }
user, err := db.GetUserByDiscordID(ctx, tx, discorduser.ID) user, err := db.GetUserByDiscordID(ctx, tx, discorduser.ID)
if err != nil { if err != nil && !db.IsBadRequest(err) {
return nil, errors.Wrap(err, "db.GetUserByDiscordID") return nil, errors.Wrap(err, "db.GetUserByDiscordID")
} }
var redirect string var redirect string

View File

@@ -0,0 +1,173 @@
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/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func GenerateFixtures(
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 {
return
}
seasonShortName := getter.String("season_short_name").TrimSpace().Required().Value
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
round := getter.Int("round").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
var season *db.Season
var league *db.League
var fixtures []*db.Fixture
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))
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
return false, nil
}
return false, errors.Wrap(err, "db.NewRound")
}
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtures")
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
})
}
func UpdateFixtures(
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 {
return
}
seasonShortName := getter.String("season_short_name").TrimSpace().Required().Value
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
allocations := getter.GetMaps("allocations")
if !getter.ValidateAndNotify(s, w, r) {
return
}
updates, err := mapUpdates(allocations)
if err != nil {
respond.BadRequest(w, errors.Wrap(err, "strconv.Atoi"))
return
}
var fixtures []*db.Fixture
if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
_, _, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.NewRound"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixtures")
}
var valid bool
fixtures, valid = updateFixtures(fixtures, updates)
if !valid {
notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil)
return false, nil
}
err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAudit(r, nil))
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
}
return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks")
}
return true, nil
}) {
return
}
notify.Success(s, w, r, "Fixtures Updated", "Fixtures successfully updated", nil)
})
}
func DeleteFixture(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureIDstr := r.PathValue("fixture_id")
fixtureID, err := strconv.Atoi(fixtureIDstr)
if err != nil {
respond.BadRequest(w, errors.Wrap(err, "strconv.Atoi"))
return
}
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))
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.DeleteFixture")
}
return true, nil
}) {
return
}
})
}
func mapUpdates(allocations []map[string]string) (map[int]int, error) {
updates := map[int]int{}
for _, v := range allocations {
id, err := strconv.Atoi(v["id"])
if err != nil {
return nil, errors.Wrap(err, "strconv.Atoi")
}
gameWeek, err := strconv.Atoi(v["game_week"])
if err != nil {
return nil, errors.Wrap(err, "strconv.Atoi")
}
updates[id] = gameWeek
}
return updates, nil
}
func updateFixtures(fixtures []*db.Fixture, updates map[int]int) ([]*db.Fixture, bool) {
updated := []*db.Fixture{}
gameWeeks := map[int]int{}
for _, fixture := range fixtures {
if gameWeek, exists := updates[fixture.ID]; exists {
fixture.GameWeek = &gameWeek
updated = append(updated, fixture)
}
gameWeeks[*fixture.GameWeek]++
}
for i := range len(gameWeeks) {
count, exists := gameWeeks[i+1]
if !exists || count < 1 {
return nil, false
}
}
return updated, true
}

View File

@@ -2,10 +2,12 @@ package handlers
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -22,12 +24,12 @@ func IsUnique(
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, err := validation.ParseForm(r) getter, err := validation.ParseForm(r)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
value := getter.String(field).TrimSpace().Required().Value value := getter.String(field).TrimSpace().Required().Value
if !getter.Validate() { if !getter.Validate() {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
unique := false unique := false
@@ -41,9 +43,10 @@ func IsUnique(
return return
} }
if unique { if unique {
w.WriteHeader(http.StatusOK) respond.OK(w)
} else { } else {
w.WriteHeader(http.StatusConflict) err := fmt.Errorf("'%s' is not unique for field '%s'", value, field)
respond.Conflict(w, err)
} }
}) })
} }

View File

@@ -11,6 +11,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
leaguesview "git.haelnorr.com/h/oslstats/internal/view/leaguesview" leaguesview "git.haelnorr.com/h/oslstats/internal/view/leaguesview"
) )
@@ -78,8 +79,7 @@ func NewLeagueSubmit(
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
} }
w.Header().Set("HX-Redirect", fmt.Sprintf("/leagues/%s", league.ShortName)) respond.HXRedirect(w, "/leagues/%s", league.ShortName)
w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "League Created", fmt.Sprintf("Successfully created league: %s", name), nil) notify.SuccessWithDelay(s, w, r, "League Created", fmt.Sprintf("Successfully created league: %s", name), nil)
}) })
} }

View File

@@ -12,6 +12,7 @@ import (
"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/discord"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
"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"
"git.haelnorr.com/h/oslstats/pkg/oauth" "git.haelnorr.com/h/oslstats/pkg/oauth"
@@ -34,10 +35,10 @@ func Login(
if r.Method == "POST" { if r.Method == "POST" {
if err != nil { if err != nil {
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err) notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
w.WriteHeader(http.StatusOK) respond.OK(w)
return return
} }
w.Header().Set("HX-Redirect", "/login") respond.HXRedirect(w, "/login")
return return
} }

View File

@@ -8,6 +8,7 @@ import (
"git.haelnorr.com/h/golib/hwsauth" "git.haelnorr.com/h/golib/hwsauth"
"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/discord"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -22,11 +23,6 @@ func Logout(
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
user := db.CurrentUser(r.Context()) user := db.CurrentUser(r.Context())
if user == nil {
// JIC - should be impossible to get here if route is protected by LoginReq
w.Header().Set("HX-Redirect", "/")
return
}
if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
token, err := user.DeleteDiscordTokens(ctx, tx) token, err := user.DeleteDiscordTokens(ctx, tx)
if err != nil { if err != nil {
@@ -48,7 +44,7 @@ func Logout(
}); !ok { }); !ok {
return return
} }
w.Header().Set("HX-Redirect", "/") respond.HXRedirect(w, "/")
}, },
) )
} }

View File

@@ -12,6 +12,7 @@ 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/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"
@@ -82,7 +83,7 @@ func Register(
return return
} }
if !unique { if !unique {
w.WriteHeader(http.StatusConflict) respond.Conflict(w, errors.New("username is taken"))
} else { } else {
err = auth.Login(w, r, user, true) err = auth.Login(w, r, user, true)
if err != nil { if err != nil {
@@ -90,7 +91,7 @@ func Register(
return return
} }
pageFrom := cookies.CheckPageFrom(w, r) pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom) respond.HXRedirect(w, "%s", pageFrom)
} }
}, },
) )

View File

@@ -25,11 +25,12 @@ func SeasonPage(
var err error var err error
season, err = db.GetSeason(ctx, tx, seasonStr) season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
if season == nil {
return true, nil
}
leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx) leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx)
if err != nil { if err != nil {
@@ -40,10 +41,6 @@ func SeasonPage(
}); !ok { }); !ok {
return return
} }
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w) renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
}) })
} }

View File

@@ -8,6 +8,7 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify" "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/throw"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
@@ -28,6 +29,10 @@ func SeasonEditPage(
var err error var err error
season, err = db.GetSeason(ctx, tx, seasonStr) season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
allLeagues, err = db.GetLeagues(ctx, tx) allLeagues, err = db.GetLeagues(ctx, tx)
@@ -38,10 +43,6 @@ func SeasonEditPage(
}); !ok { }); !ok {
return return
} }
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.EditPage(season, allLeagues), s, r, w) renderSafely(seasonsview.EditPage(season, allLeagues), s, r, w)
}) })
} }
@@ -79,11 +80,12 @@ func SeasonEditSubmit(
var err error var err error
season, err = db.GetSeason(ctx, tx, seasonStr) season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
if season == nil {
return false, errors.New("season does not exist")
}
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.NewAudit(r, nil))
if err != nil { if err != nil {
return false, errors.Wrap(err, "season.Update") return false, errors.Wrap(err, "season.Update")
@@ -93,13 +95,7 @@ func SeasonEditSubmit(
return return
} }
if season == nil { respond.HXRedirect(w, "/seasons/%s", season.ShortName)
throw.NotFound(s, w, r, r.URL.Path)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil) notify.SuccessWithDelay(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
}) })
} }

View File

@@ -38,6 +38,10 @@ func SeasonLeagueAddTeam(
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, seasonStr, leagueStr, teamID, db.NewAudit(r, nil))
if err != nil { if err != nil {
if db.IsBadRequest(err) {
w.WriteHeader(http.StatusBadRequest)
return false, nil
}
return false, errors.Wrap(err, "db.NewTeamParticipation") return false, errors.Wrap(err, "db.NewTeamParticipation")
} }
return true, nil return true, nil

View File

@@ -22,12 +22,15 @@ func SeasonLeaguePage(
leagueStr := r.PathValue("league_short_name") leagueStr := r.PathValue("league_short_name")
var season *db.Season var season *db.Season
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) season, _, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { 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") return false, errors.Wrap(err, "db.GetSeasonLeague")
} }
return true, nil return true, nil
@@ -35,11 +38,6 @@ func SeasonLeaguePage(
return return
} }
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
defaultTab := season.GetDefaultTab() defaultTab := season.GetDefaultTab()
redirectURL := fmt.Sprintf( redirectURL := fmt.Sprintf(
"/seasons/%s/leagues/%s/%s", "/seasons/%s/leagues/%s/%s",

View File

@@ -7,7 +7,7 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -28,6 +28,10 @@ func SeasonLeagueFinalsPage(
var err error var err error
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { 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") return false, errors.Wrap(err, "db.GetSeasonLeague")
} }
return true, nil return true, nil
@@ -35,11 +39,6 @@ func SeasonLeagueFinalsPage(
return return
} }
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueFinalsPage(season, league), s, r, w)
} else { } else {

View File

@@ -6,8 +6,9 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -18,32 +19,96 @@ func SeasonLeagueFixturesPage(
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 league *db.League var league *db.League
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, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetSeasonLeague") if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixtures")
} }
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueFixturesPage(season, league, fixtures), s, r, w)
} else { } else {
renderSafely(seasonsview.SeasonLeagueFixtures(), s, r, w) renderSafely(seasonsview.SeasonLeagueFixtures(season, league, fixtures), s, r, w)
} }
}) })
} }
func SeasonLeagueManageFixturesPage(
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")
var season *db.Season
var league *db.League
var fixtures []*db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixtures")
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.SeasonLeagueManageFixturesPage(season, league, fixtures), s, r, w)
})
}
func SeasonLeagueDeleteFixtures(
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")
var season *db.Season
var league *db.League
var fixtures []*db.Fixture
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))
if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures"))
return false, nil
}
return false, errors.Wrap(err, "db.DeleteAllFixtures")
}
season, league, fixtures, err = db.GetFixtures(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtures")
}
return true, nil
}) {
return
}
renderSafely(seasonsview.SeasonLeagueManageFixtures(season, league, fixtures), s, r, w)
})
}

View File

@@ -28,6 +28,10 @@ func SeasonLeagueStatsPage(
var err error var err error
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { 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") return false, errors.Wrap(err, "db.GetSeasonLeague")
} }
return true, nil return true, nil
@@ -35,11 +39,6 @@ func SeasonLeagueStatsPage(
return return
} }
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueStatsPage(season, league), s, r, w)
} else { } else {

View File

@@ -28,18 +28,16 @@ func SeasonLeagueTablePage(
var err error var err error
season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) season, league, _, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { 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") return false, errors.Wrap(err, "db.GetSeasonLeague")
} }
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
if r.Method == "GET" { if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w) renderSafely(seasonsview.SeasonLeagueTablePage(season, league), s, r, w)
} else { } else {

View File

@@ -30,6 +30,10 @@ func SeasonLeagueTeamsPage(
var err error var err error
season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr) season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil { 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") return false, errors.Wrap(err, "db.GetSeasonLeague")
} }
@@ -46,11 +50,6 @@ func SeasonLeagueTeamsPage(
return return
} }
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
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), s, r, w)
} else { } else {

View File

@@ -10,6 +10,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
) )
@@ -26,6 +27,10 @@ func SeasonAddLeague(
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, seasonStr, leagueStr, db.NewAudit(r, nil))
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.NewSeasonLeague") return false, errors.Wrap(err, "db.NewSeasonLeague")
} }
@@ -64,13 +69,17 @@ func SeasonRemoveLeague(
var err error var err error
season, err = db.GetSeason(ctx, tx, seasonStr) season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
if season == nil {
return false, errors.New("season not found")
}
err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAudit(r, nil)) err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAudit(r, nil))
if err != nil { if err != nil {
if db.IsBadRequest(err) {
respond.BadRequest(w, err)
}
return false, errors.Wrap(err, "season.RemoveLeague") return false, errors.Wrap(err, "season.RemoveLeague")
} }

View File

@@ -8,6 +8,7 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"git.haelnorr.com/h/timefmt" "git.haelnorr.com/h/timefmt"
@@ -83,8 +84,7 @@ 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
} }
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName)) respond.HXRedirect(w, "/seasons/%s", season.ShortName)
w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil) notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
}) })
} }

View File

@@ -2,10 +2,9 @@ package handlers
import ( import (
"net/http" "net/http"
"path/filepath"
"strings"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
) )
@@ -23,41 +22,8 @@ func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
// Explicitly set Content-Type for CSS files respond.ContentType(w, r)
if strings.HasSuffix(r.URL.Path, ".css") {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
} else if strings.HasSuffix(r.URL.Path, ".js") {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
} else if strings.HasSuffix(r.URL.Path, ".ico") {
w.Header().Set("Content-Type", "image/x-icon")
} else {
// Let Go detect the content type for other files
ext := filepath.Ext(r.URL.Path)
if contentType := mimeTypes[ext]; contentType != "" {
w.Header().Set("Content-Type", contentType)
}
}
fs.ServeHTTP(w, r) fs.ServeHTTP(w, r)
}, },
) )
} }
// Common MIME types for static files
var mimeTypes = map[string]string{
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".xml": "application/xml; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
}

View File

@@ -6,6 +6,7 @@ import (
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -20,7 +21,7 @@ func IsTeamShortNamesUnique(
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, err := validation.ParseForm(r) getter, err := validation.ParseForm(r)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) respond.BadRequest(w, err)
return return
} }
@@ -28,12 +29,12 @@ func IsTeamShortNamesUnique(
altShortName := getter.String("alt_short_name").TrimSpace().ToUpper().MaxLength(3).Value altShortName := getter.String("alt_short_name").TrimSpace().ToUpper().MaxLength(3).Value
if shortName == "" || altShortName == "" { if shortName == "" || altShortName == "" {
w.WriteHeader(http.StatusOK) respond.OK(w)
return return
} }
if shortName == altShortName { if shortName == altShortName {
w.WriteHeader(http.StatusConflict) respond.Conflict(w, errors.New("short names cannot be the same"))
return return
} }
@@ -49,9 +50,9 @@ func IsTeamShortNamesUnique(
} }
if isUnique { if isUnique {
w.WriteHeader(http.StatusOK) respond.OK(w)
} else { } else {
w.WriteHeader(http.StatusConflict) respond.Conflict(w, errors.New("short name combination is taken"))
} }
}) })
} }

View File

@@ -32,31 +32,10 @@ func TeamsPage(
}); !ok { }); !ok {
return return
} }
renderSafely(teamsview.ListPage(teams), s, r, w) if r.Method == "GET" {
}) renderSafely(teamsview.ListPage(teams), s, r, w)
} } else {
renderSafely(teamsview.TeamsList(teams), s, r, w)
// TeamsList renders just the teams list, for use with POST requests and HTMX }
func TeamsList(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
var teams *db.List[db.Team]
if ok := conn.WithReadTx(s, w, r, 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)
}) })
} }

View File

@@ -11,6 +11,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview" teamsview "git.haelnorr.com/h/oslstats/internal/view/teamsview"
) )
@@ -88,8 +89,7 @@ func NewTeamSubmit(
notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil) notify.Warn(s, w, r, "Duplicate Short Names", "This combination of short names is already taken.", nil)
return return
} }
w.Header().Set("HX-Redirect", "/teams") respond.HXRedirect(w, "/teams")
w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil) notify.SuccessWithDelay(s, w, r, "Team Created", fmt.Sprintf("Successfully created team: %s", name), nil)
}) })
} }

View File

@@ -33,4 +33,9 @@ const (
UsersUpdate Permission = "users.update" UsersUpdate Permission = "users.update"
UsersBan Permission = "users.ban" UsersBan Permission = "users.ban"
UsersManageRoles Permission = "users.manage_roles" UsersManageRoles Permission = "users.manage_roles"
// Fixtures permissions
FixturesManage Permission = "fixtures.manage"
FixturesCreate Permission = "fixtures.create"
FixturesDelete Permission = "fixtures.delete"
) )

36
internal/respond/error.go Normal file
View File

@@ -0,0 +1,36 @@
// Package respond provides utilities for raw HTTP responses that don't fit into the throw or notify categories
package respond
import (
"encoding/json"
"fmt"
"net/http"
)
func OK(w http.ResponseWriter) {
w.WriteHeader(http.StatusOK)
}
func BadRequest(w http.ResponseWriter, err error) {
respondError(w, http.StatusBadRequest, err)
}
func NotFound(w http.ResponseWriter, err error) {
respondError(w, http.StatusNotFound, err)
}
func Conflict(w http.ResponseWriter, err error) {
respondError(w, http.StatusConflict, err)
}
func respondError(w http.ResponseWriter, statusCode int, err error) {
details := map[string]any{
"error": statusCode,
"details": fmt.Sprintf("%s", err),
}
resp, err := json.Marshal(details)
w.WriteHeader(statusCode)
if err == nil {
_, _ = w.Write(resp)
}
}

View File

@@ -0,0 +1,39 @@
package respond
import (
"fmt"
"net/http"
"path/filepath"
)
func HXRedirect(w http.ResponseWriter, format string, a ...any) {
w.Header().Set("HX-Redirect", fmt.Sprintf(format, a...))
w.WriteHeader(http.StatusOK)
}
func ContentType(w http.ResponseWriter, r *http.Request) {
ext := filepath.Ext(r.URL.Path)
if contentType := mimeTypes[ext]; contentType != "" {
w.Header().Set("Content-Type", contentType)
}
}
// Common MIME types for static files
var mimeTypes = map[string]string{
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".xml": "application/xml; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
}

View File

@@ -160,17 +160,40 @@ func addRoutes(
}, },
} }
fixturesRoutes := []hws.Route{
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/fixtures/manage",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.SeasonLeagueManageFixturesPage(s, conn)),
},
{
Path: "/fixtures/generate",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesCreate)(handlers.GenerateFixtures(s, conn)),
},
{
Path: "/fixtures/update-game-weeks",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.UpdateFixtures(s, conn)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/fixtures",
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.SeasonLeagueDeleteFixtures(s, conn)),
},
{
Path: "/fixtures/{fixture_id}",
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.FixturesDelete)(handlers.DeleteFixture(s, conn)),
},
}
teamRoutes := []hws.Route{ teamRoutes := []hws.Route{
{ {
Path: "/teams", Path: "/teams",
Method: hws.MethodGET, Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.TeamsPage(s, conn), Handler: handlers.TeamsPage(s, conn),
}, },
{
Path: "/teams",
Method: hws.MethodPOST,
Handler: handlers.TeamsList(s, conn),
},
{ {
Path: "/teams/new", Path: "/teams/new",
Method: hws.MethodGET, Method: hws.MethodGET,
@@ -326,6 +349,7 @@ func addRoutes(
routes = append(routes, adminRoutes...) routes = append(routes, adminRoutes...)
routes = append(routes, seasonRoutes...) routes = append(routes, seasonRoutes...)
routes = append(routes, leagueRoutes...) routes = append(routes, leagueRoutes...)
routes = append(routes, fixturesRoutes...)
routes = append(routes, teamRoutes...) routes = append(routes, teamRoutes...)
// Register the routes with the server // Register the routes with the server

View File

@@ -2,6 +2,9 @@ package validation
import ( import (
"net/http" "net/http"
"regexp"
"strconv"
"strings"
"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"
@@ -25,7 +28,23 @@ func (f *FormGetter) Get(key string) string {
} }
func (f *FormGetter) GetList(key string) []string { func (f *FormGetter) GetList(key string) []string {
return f.r.Form[key] return strings.Split(f.Get(key), ",")
}
func (f *FormGetter) GetMaps(key string) []map[string]string {
var result []map[string]string
for key, values := range f.r.Form {
re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z]+)\\]")
matches := re.FindStringSubmatch(key)
if len(matches) >= 3 {
index, _ := strconv.Atoi(matches[1])
for index >= len(result) {
result = append(result, map[string]string{})
}
result[index][matches[2]] = values[0]
}
}
return result
} }
func (f *FormGetter) getChecks() []*ValidationRule { func (f *FormGetter) getChecks() []*ValidationRule {

View File

@@ -1,15 +1,85 @@
package seasonsview package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db" 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 SeasonLeagueFixturesPage(season *db.Season, league *db.League) { templ SeasonLeagueFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture) {
@SeasonLeagueLayout("fixtures", season, league) { @SeasonLeagueLayout("fixtures", season, league) {
@SeasonLeagueFixtures() @SeasonLeagueFixtures(season, league, fixtures)
} }
} }
templ SeasonLeagueFixtures() { templ SeasonLeagueFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center"> {{
<p class="text-subtext0 text-lg">Coming Soon...</p> permCache := contexts.Permissions(ctx)
canManage := permCache.HasPermission(permissions.FixturesManage)
// Group fixtures by game week (only allocated ones)
type gameWeekGroup struct {
Week int
Fixtures []*db.Fixture
}
groups := []gameWeekGroup{}
groupMap := map[int]int{} // week -> index in groups
for _, f := range fixtures {
if f.GameWeek == nil {
continue
}
idx, exists := groupMap[*f.GameWeek]
if !exists {
idx = len(groups)
groupMap[*f.GameWeek] = idx
groups = append(groups, gameWeekGroup{Week: *f.GameWeek, Fixtures: []*db.Fixture{}})
}
groups[idx].Fixtures = append(groups[idx].Fixtures, f)
}
}}
<div>
if canManage {
<div class="flex justify-end mb-4">
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/fixtures/manage", season.ShortName, league.ShortName)) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition"
>
Manage Fixtures
</a>
</div>
}
if len(groups) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No fixtures scheduled yet.</p>
</div>
} else {
<div class="space-y-4">
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">
<h3 class="text-lg font-bold text-text">Game Week { fmt.Sprint(group.Week) }</h3>
</div>
<div class="divide-y divide-surface1">
for _, fixture := range group.Fixtures {
<div class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
R{ fmt.Sprint(fixture.Round) }
</span>
<span class="text-text">
{ fixture.HomeTeam.Name }
</span>
<span class="text-subtext0 text-sm">vs</span>
<span class="text-text">
{ fixture.AwayTeam.Name }
</span>
</div>
</div>
}
</div>
</div>
}
</div>
}
</div> </div>
} }

View File

@@ -0,0 +1,477 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "encoding/json"
import "fmt"
type FixtureJSON struct {
ID int `json:"id"`
HomeTeam string `json:"homeTeam"`
AwayTeam string `json:"awayTeam"`
Round int `json:"round"`
GameWeek *int `json:"gameWeek"`
}
func fixtureToJSON(f *db.Fixture) FixtureJSON {
return FixtureJSON{
ID: f.ID,
HomeTeam: f.HomeTeam.Name,
AwayTeam: f.AwayTeam.Name,
Round: f.Round,
GameWeek: f.GameWeek,
}
}
func fixturesToJSON(fixtures []*db.Fixture) string {
data := make([]FixtureJSON, len(fixtures))
for i, f := range fixtures {
data[i] = fixtureToJSON(f)
}
b, _ := json.Marshal(data)
return string(b)
}
templ SeasonLeagueManageFixturesPage(season *db.Season, league *db.League, fixtures []*db.Fixture) {
@baseview.Layout(fmt.Sprintf("%s - %s - Manage Fixtures", season.Name, league.Name)) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
<div class="flex items-center gap-4 mb-6">
<a
href={ templ.SafeURL(fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", season.ShortName, league.ShortName)) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
&larr; Back to Fixtures
</a>
<h1 class="text-2xl font-bold text-text">Manage Fixtures - { season.Name } - { league.Name }</h1>
</div>
@SeasonLeagueManageFixtures(season, league, fixtures)
</div>
}
}
templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures []*db.Fixture) {
<div
id="manage-fixtures-content"
x-data={ fmt.Sprintf("fixturesManager(%s, '%s', '%s')", fixturesToJSON(fixtures), season.ShortName, league.ShortName) }
x-cloak
>
<!-- Controls -->
<div class="flex items-center gap-3 mb-6">
<!-- Generate -->
<div class="flex items-center gap-2">
<input
type="number"
x-model.number="generateRounds"
min="1"
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"
/>
<button
@click="generate()"
:disabled="isGenerating || generateRounds < 1"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition
disabled:bg-blue/40 disabled:cursor-not-allowed"
>
<span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span>
</button>
</div>
<!-- Clear All -->
<button
x-show="allFixtures.length > 0"
@click="clearAll()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-red hover:bg-red/80 text-mantle transition"
>
Clear All
</button>
<!-- Save -->
<button
x-show="unsavedChanges"
@click="save()"
:disabled="isSaving || !canSave()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed ml-auto"
>
<span x-text="isSaving ? 'Saving...' : 'Save'"></span>
</button>
<span
x-show="unsavedChanges && !canSave()"
class="text-yellow text-xs ml-2"
>
All game weeks must have at least 1 fixture
</span>
</div>
<!-- Main content panels -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- LEFT PANEL: Game Weeks -->
<div class="lg:col-span-2 bg-surface0 border border-surface1 rounded-lg p-4 min-h-96">
<!-- List View -->
<div x-show="selectedGameWeek === null">
<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">
No game weeks yet. Add one to start allocating fixtures.
</div>
<div class="space-y-2">
<template x-for="week in allGameWeekNumbers" :key="week">
<div
class="bg-mantle border border-surface1 rounded-lg p-4
hover:bg-surface1 transition group relative"
:class="{ 'border-blue/50': dropTarget === week }"
@click="selectGameWeek(week)"
@dragover.prevent="dropTarget = week"
@dragleave="if (dropTarget === week) dropTarget = null"
@drop.prevent="onDrop(week)"
>
<div class="flex items-center justify-between hover:cursor-pointer">
<div>
<span class="font-semibold text-text" x-text="'Game Week ' + week"></span>
<span class="text-subtext0 text-sm ml-2">
(<span x-text="getFixtureCount(week)"></span>
<span x-text="getFixtureCount(week) === 1 ? 'fixture' : 'fixtures'"></span>)
</span>
</div>
<!-- Preview of first few fixtures -->
<div class="flex items-center gap-2">
<template x-for="f in getPreview(week)" :key="f.id">
<span class="text-xs text-subtext0 bg-surface0 px-2 py-0.5 rounded">
<span x-text="f.homeTeam"></span> vs <span x-text="f.awayTeam"></span>
</span>
</template>
<span
x-show="getFixtureCount(week) > 3"
class="text-xs text-subtext1"
x-text="'+ ' + (getFixtureCount(week) - 3) + ' more'"
></span>
</div>
</div>
</div>
</template>
</div>
<!-- Add Game Week -->
<button
@click="addGameWeek()"
class="w-full mt-3 py-3 border-2 border-dashed border-surface1 rounded-lg
text-subtext0 hover:text-text hover:border-surface2
transition hover:cursor-pointer text-sm"
>
+ Add Game Week
</button>
</div>
<!-- Detail View -->
<div x-show="selectedGameWeek !== null">
<div class="flex items-center gap-3 mb-4">
<button
@click="backToList()"
class="text-blue hover:text-blue/80 transition hover:cursor-pointer text-sm"
>
&larr; Back
</button>
<h3 class="text-lg font-bold text-text" x-text="'Game Week ' + selectedGameWeek"></h3>
<span class="text-subtext0 text-sm">
(<span x-text="getFixtureCount(selectedGameWeek)"></span>
<span x-text="getFixtureCount(selectedGameWeek) === 1 ? 'fixture' : 'fixtures'"></span>)
</span>
<!-- Delete Week (only if empty) -->
<button
x-show="getFixtureCount(selectedGameWeek) === 0"
@click="deleteGameWeek(selectedGameWeek)"
class="ml-auto text-red hover:text-red/80 transition hover:cursor-pointer text-sm"
>
Delete Week
</button>
</div>
<!-- Drop Zone -->
<div
class="bg-mantle border-2 rounded-lg p-4 min-h-48 transition-colors"
:class="dropTarget === selectedGameWeek ? 'border-blue/50' : 'border-surface1'"
@dragover.prevent="dropTarget = selectedGameWeek"
@dragleave="if (dropTarget === selectedGameWeek) dropTarget = null"
@drop.prevent="onDrop(selectedGameWeek)"
>
<div x-show="getFixtureCount(selectedGameWeek) === 0" class="text-subtext1 text-sm italic text-center py-8">
Drop fixtures here
</div>
<div class="space-y-2">
<template x-for="fixture in getGameWeekFixtures(selectedGameWeek)" :key="fixture.id">
<div
draggable="true"
@dragstart="onDragStart($event, fixture)"
@dragend="onDragEnd()"
class="bg-surface0 border border-surface1 rounded-lg px-4 py-3
cursor-grab active:cursor-grabbing hover:bg-surface1 transition
flex items-center justify-between"
:class="{ 'opacity-50': draggedFixture && draggedFixture.id === fixture.id }"
>
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-mantle px-2 py-0.5 rounded">
R<span x-text="fixture.round"></span>
</span>
<span class="text-text" x-text="fixture.homeTeam"></span>
<span class="text-subtext0 text-sm">vs</span>
<span class="text-text" x-text="fixture.awayTeam"></span>
</div>
<button
@click.stop="unallocateFixture(fixture)"
class="text-subtext0 hover:text-red transition hover:cursor-pointer text-xs"
title="Remove from game week"
>
&#10005;
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- RIGHT PANEL: Unallocated Fixtures -->
<div class="bg-surface0 border border-surface1 rounded-lg p-4 min-h-96">
<h3 class="text-lg font-bold text-text mb-4">
Unallocated
<span class="text-subtext0 font-normal text-sm">
(<span x-text="unallocatedFixtures.length"></span>)
</span>
</h3>
<div
class="space-y-2 max-h-[600px] overflow-y-auto transition-colors rounded-lg p-1"
:class="dropTarget === 'unallocated' ? 'bg-surface1/30' : ''"
@dragover.prevent="dropTarget = 'unallocated'"
@dragleave="if (dropTarget === 'unallocated') dropTarget = null"
@drop.prevent="onDrop('unallocated')"
>
<div x-show="unallocatedFixtures.length === 0" class="text-subtext1 text-sm italic text-center py-8">
<span x-show="allFixtures.length === 0">No fixtures generated yet.</span>
<span x-show="allFixtures.length > 0">All fixtures allocated!</span>
</div>
<template x-for="fixture in unallocatedFixtures" :key="fixture.id">
<div
draggable="true"
@dragstart="onDragStart($event, fixture)"
@dragend="onDragEnd()"
class="bg-mantle border border-surface1 rounded-lg px-4 py-3
cursor-grab active:cursor-grabbing hover:bg-surface1 transition"
:class="{ 'opacity-50': draggedFixture && draggedFixture.id === fixture.id }"
>
<div class="flex items-center gap-3">
<span class="text-xs font-mono text-subtext0 bg-surface0 px-2 py-0.5 rounded">
R<span x-text="fixture.round"></span>
</span>
<span class="text-text" x-text="fixture.homeTeam"></span>
<span class="text-subtext0 text-sm">vs</span>
<span class="text-text" x-text="fixture.awayTeam"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Alpine.js component -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('fixturesManager', (initialFixtures, seasonShortName, leagueShortName) => ({
allFixtures: initialFixtures || [],
seasonShortName: seasonShortName,
leagueShortName: leagueShortName,
// UI state
selectedGameWeek: null,
unsavedChanges: false,
isSaving: false,
isGenerating: false,
generateRounds: 1,
// Drag state
draggedFixture: null,
dropTarget: null,
// Computed
get unallocatedFixtures() {
return this.allFixtures
.filter(f => f.gameWeek === null)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
get allGameWeekNumbers() {
const weeks = new Set();
for (const f of this.allFixtures) {
if (f.gameWeek !== null) {
weeks.add(f.gameWeek);
}
}
// Also include manually added empty weeks
for (const w of this._emptyWeeks || []) {
weeks.add(w);
}
return [...weeks].sort((a, b) => a - b);
},
// Track empty weeks that user created
_emptyWeeks: [],
getGameWeekFixtures(week) {
return this.allFixtures
.filter(f => f.gameWeek === week)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
getFixtureCount(week) {
return this.allFixtures.filter(f => f.gameWeek === week).length;
},
getPreview(week) {
return this.getGameWeekFixtures(week).slice(0, 3);
},
// Game week management
addGameWeek() {
const existing = this.allGameWeekNumbers;
const next = existing.length > 0 ? Math.max(...existing) + 1 : 1;
this._emptyWeeks.push(next);
this.unsavedChanges = true;
},
deleteGameWeek(week) {
if (this.getFixtureCount(week) > 0) return;
this._emptyWeeks = this._emptyWeeks.filter(w => w !== week);
if (this.selectedGameWeek === week) {
this.selectedGameWeek = null;
}
this.unsavedChanges = true;
},
selectGameWeek(week) {
this.selectedGameWeek = week;
},
backToList() {
this.selectedGameWeek = null;
},
// Drag and drop
onDragStart(event, fixture) {
this.draggedFixture = fixture;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', fixture.id);
},
onDragEnd() {
this.draggedFixture = null;
this.dropTarget = null;
},
onDrop(target) {
if (!this.draggedFixture) return;
const fixture = this.allFixtures.find(f => f.id === this.draggedFixture.id);
if (!fixture) return;
if (target === 'unallocated') {
fixture.gameWeek = null;
} else {
fixture.gameWeek = target;
// Remove from empty weeks if it now has fixtures
this._emptyWeeks = this._emptyWeeks.filter(w => w !== target);
}
this.unsavedChanges = true;
this.draggedFixture = null;
this.dropTarget = null;
},
unallocateFixture(fixture) {
const f = this.allFixtures.find(ff => ff.id === fixture.id);
if (f) {
const oldWeek = f.gameWeek;
f.gameWeek = null;
// If the old week is now empty, track it
if (oldWeek !== null && this.getFixtureCount(oldWeek) === 0) {
this._emptyWeeks.push(oldWeek);
}
this.unsavedChanges = true;
}
},
// Validation
canSave() {
const weeks = this.allGameWeekNumbers;
if (weeks.length === 0) return false;
for (const week of weeks) {
if (this.getFixtureCount(week) === 0) return false;
}
return true;
},
// Server actions
generate() {
if (this.generateRounds < 1) return;
this.isGenerating = true;
const form = new FormData();
form.append('season_short_name', this.seasonShortName);
form.append('league_short_name', this.leagueShortName);
form.append('round', this.generateRounds);
htmx.ajax('POST', '/fixtures/generate', {
target: '#manage-fixtures-content',
swap: 'outerHTML',
values: Object.fromEntries(form)
}).finally(() => {
this.isGenerating = false;
});
},
save() {
if (!this.canSave()) return;
this.isSaving = true;
const form = new FormData();
form.append('season_short_name', this.seasonShortName);
form.append('league_short_name', this.leagueShortName);
this.allFixtures.forEach((f, i) => {
form.append('allocations[' + i + '][id]', f.id);
form.append('allocations[' + i + '][game_week]', f.gameWeek !== null ? f.gameWeek : 0);
});
fetch('/fixtures/update-game-weeks', {
method: 'POST',
body: form
}).then(response => {
if (response.ok) {
this.unsavedChanges = false;
}
this.isSaving = false;
}).catch(() => {
this.isSaving = false;
});
},
clearAll() {
const seasonShort = this.seasonShortName;
const leagueShort = this.leagueShortName;
window.dispatchEvent(new CustomEvent('confirm-action', {
detail: {
title: 'Clear All Fixtures',
message: 'This will delete all fixtures for this league. This action cannot be undone.',
action: () => {
htmx.ajax('DELETE',
'/seasons/' + seasonShort + '/leagues/' + leagueShort + '/fixtures',
{
target: '#manage-fixtures-content',
swap: 'outerHTML'
}
);
}
}
}));
},
}));
});
</script>
</div>
}

View File

@@ -114,7 +114,7 @@ _migrate-status:
{{bin}}/{{entrypoint}} --migrate-status --envfile $ENVFILE {{bin}}/{{entrypoint}} --migrate-status --envfile $ENVFILE
[private] [private]
_migrate-new name: && _migrate-status _migrate-new name: && _build _migrate-status
{{bin}}/{{entrypoint}} --migrate-create {{name}} {{bin}}/{{entrypoint}} --migrate-create {{name}}
# Hard reset the database # Hard reset the database