we have fixtures ladies and gentleman

This commit is contained in:
2026-02-15 19:56:03 +11:00
parent ef8c022e60
commit 0c5a88c309
23 changed files with 1355 additions and 78 deletions

View File

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

View File

@@ -6,7 +6,7 @@ import (
)
func IsBadRequest(err error) bool {
return strings.HasPrefix(err.Error(), "bad request:")
return strings.Contains(err.Error(), "bad request:")
}
func BadRequest(err string) error {
@@ -18,14 +18,14 @@ func BadRequestNotFound(resource, field string, value any) error {
return BadRequest(errStr)
}
func BadRequestNotAssociated(parent, child string, parentID, childID any) error {
errStr := fmt.Sprintf("%s (ID: %v) not associated with %s (ID: %v)",
child, childID, parent, parentID)
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 string, parentID, childID any) error {
errStr := fmt.Sprintf("%s (ID: %v) already associated with %s (ID: %v)",
child, childID, parent, parentID)
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

@@ -92,6 +92,11 @@ func (l *listgetter[T]) Where(query string, args ...any) *listgetter[T] {
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] {
l.q = l.q.Relation(name, apply...)
return l
@@ -130,6 +135,14 @@ func (l *listgetter[T]) GetPaged(ctx context.Context, pageOpts, defaults *PageOp
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) {
err := l.q.Scan(ctx)
if err != nil && errors.Is(err, sql.ErrNoRows) {

View File

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

View File

@@ -10,13 +10,13 @@ import (
type League struct {
bun.BaseModel `bun:"table:leagues,alias:l"`
ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,unique,notnull"`
ShortName string `bun:"short_name,unique,notnull"`
Description string `bun:"description"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
Description string `bun:"description" json:"description"`
Seasons []Season `bun:"m2m:season_leagues,join:League=Season"`
Teams []Team `bun:"m2m:team_participations,join:League=Team"`
Seasons []Season `bun:"m2m:season_leagues,join:League=Season" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:League=Team" json:"-"`
}
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
func validateMigrations(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "go", "build",
"-o", "/dev/null", "./cmd/oslstats/migrations")
"-o", "/dev/null", "./internal/db/migrations")
output, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -31,12 +31,12 @@ func init() {
Model(&permissionsData).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "dbConn.NewInsert")
return errors.Wrap(err, "conn.NewInsert")
}
return nil
},
// DOWN migration
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
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,17 +28,17 @@ const (
type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"`
ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,unique,notnull"`
ShortName string `bun:"short_name,unique,notnull"`
StartDate time.Time `bun:"start_date,notnull"`
EndDate bun.NullTime `bun:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
SlapVersion string `bun:"slap_version,notnull,default:'rebound'"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
StartDate time.Time `bun:"start_date,notnull" json:"start_date"`
EndDate bun.NullTime `bun:"end_date" json:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
}
// NewSeason creats a new season
@@ -163,11 +163,21 @@ func (s *Season) GetDefaultTab() string {
}
}
func (s *Season) HasLeague(leagueID int) bool {
for _, league := range s.Leagues {
if league.ID == leagueID {
func (s *Season) HasLeague(league *League) bool {
for _, league_ := range s.Leagues {
if league_.ID == league.ID {
return true
}
}
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")
}
// Get the season
season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason")
}
// Get the league
league, err := GetLeague(ctx, tx, leagueShortName)
league, err := season.GetLeague(leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague")
}
if !season.HasLeague(league.ID) {
return nil, nil, nil, BadRequestNotAssociated("season", "league", seasonShortName, leagueShortName)
return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
// Get all teams participating in this season+league
@@ -63,8 +58,9 @@ func NewSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShor
if err != nil {
return errors.Wrap(err, "GetLeague")
}
if season.HasLeague(league.ID) {
return BadRequestAssociated("season", "league", seasonShortName, leagueShortName)
if season.HasLeague(league) {
return BadRequestAssociated("season", "league",
"id", "id", season.ID, league.ID)
}
seasonLeague := &SeasonLeague{
SeasonID: season.ID,
@@ -84,12 +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 {
league, err := GetLeague(ctx, tx, leagueShortName)
league, err := s.GetLeague(leagueShortName)
if err != nil {
return errors.Wrap(err, "GetLeague")
}
if !s.HasLeague(league.ID) {
return errors.New("league not in season")
return errors.Wrap(err, "s.GetLeague")
}
info := &AuditInfo{
string(permissions.SeasonsRemoveLeague),
@@ -107,3 +100,12 @@ func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName st
}
return nil
}
func (t *Team) InTeams(teams []*Team) bool {
for _, team := range teams {
if t.ID == team.ID {
return true
}
}
return false
}

View File

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

View File

@@ -23,19 +23,17 @@ func NewTeamParticipation(ctx context.Context, tx bun.Tx,
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason")
}
league, err := GetLeague(ctx, tx, leagueShortName)
league, err := season.GetLeague(leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague")
}
if !season.HasLeague(league.ID) {
return nil, nil, nil, BadRequestNotAssociated("season", "league", seasonShortName, leagueShortName)
return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
team, err := GetTeam(ctx, tx, teamID)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetTeam")
}
if team.InSeason(season.ID) {
return nil, nil, nil, BadRequestAssociated("season", "team", seasonShortName, teamID)
return nil, nil, nil, BadRequestAssociated("season", "team",
"id", "id", season.ID, team.ID)
}
participation := &TeamParticipation{
SeasonID: season.ID,

View File

@@ -15,12 +15,12 @@ import (
type User struct {
bun.BaseModel `bun:"table:users,alias:u"`
ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key)
Username string `bun:"username,unique"` // Username (unique)
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database
DiscordID string `bun:"discord_id,unique"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Username string `bun:"username,unique" json:"username"`
CreatedAt int64 `bun:"created_at" json:"created_at"`
DiscordID string `bun:"discord_id,unique" json:"discord_id"`
Roles []*Role `bun:"m2m:user_roles,join:User=Role"`
Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"`
}
func (u *User) GetID() int {