Compare commits

...

31 Commits

Author SHA1 Message Date
51428e061d players now created with a name 2026-03-05 18:32:55 +11:00
779888921e fixed remoteip in auditlogs not using correct value 2026-03-05 18:32:55 +11:00
9a14c2f677 removed migration validation as it is redudant 2026-03-05 18:32:55 +11:00
b3428d15e5 updated ezconf 2026-03-05 18:32:55 +11:00
2fd5bcf4f5 added free agents 2026-03-05 18:32:55 +11:00
bfd62cf7a3 added league table 2026-03-05 18:32:55 +11:00
3d38e87d29 added stats to team page 2026-03-05 18:32:55 +11:00
9a143da972 added log file uploading and match results 2026-03-05 18:32:55 +11:00
2fee14680a updated slapapi 2026-03-05 18:32:55 +11:00
b07c486421 added fixture scheduling 2026-03-05 18:32:55 +11:00
23a97787d5 added team rosters to season_league overview 2026-03-05 18:32:55 +11:00
53102f561a added team view to season_leagues 2026-03-05 18:32:55 +11:00
98b110ee44 added player names 2026-03-05 18:32:55 +11:00
970346f15d made draft seasons redirect to season_league view 2026-03-05 18:32:55 +11:00
9eb252da3e removed print from preview_middleware 2026-03-05 18:32:55 +11:00
f9f29bc278 Revert partial changes from 9db855f, keeping changes to preview_middleware and teams_new 2026-03-05 18:32:55 +11:00
f2ce119175 fixed bug with fixture generation 2026-03-05 18:32:55 +11:00
a4fd566d13 added an autoallocate fixtures function 2026-03-05 18:32:55 +11:00
59748b9bb9 draft seasons get special treatment :) 2026-03-05 18:32:55 +11:00
25a2109d1e added season types and changed new season to be a modal 2026-03-05 18:32:55 +11:00
c16db1bf60 slapid and player now links when registering 2026-03-05 18:32:55 +11:00
35eb2e695a added slapapi 2026-03-05 18:32:55 +11:00
317fb9a070 added players 2026-03-05 18:32:55 +11:00
fbea6569cf im actually goated 2026-03-05 18:32:55 +11:00
366aebfdf3 we have fixtures ladies and gentleman 2026-03-05 18:32:55 +11:00
c5f6fe6098 everybody loves a refactor 2026-03-05 18:32:55 +11:00
2944443143 updated season league view page 2026-03-05 18:32:55 +11:00
f9283c0563 big ole refactor 2026-03-05 18:32:55 +11:00
e34bec2f9d admin page updates 2026-03-05 18:32:55 +11:00
295e373f37 admin page updates 2026-03-05 18:32:55 +11:00
f51053212e removed env 2026-03-05 18:32:38 +11:00
188 changed files with 16414 additions and 2711 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env
.test.env
*.db*
.logs/
server.log

124
.test.env
View File

@@ -1,124 +0,0 @@
# Environment Configuration
# Generated by ezconf
#
# Variables marked as (required) must be set
# Variables with defaults can be left commented out to use the default value
# HLog Configuration
###################
# Log level for the logger - trace, debug, info, warn, error, fatal, panic (default: info)
LOG_LEVEL=trace
# Output destination for logs - console, file, or both (default: console)
# LOG_OUTPUT=console
# Directory path for log files (required)
LOG_DIR=
# Name of the log file (required)
LOG_FILE_NAME=
# Append to existing log file or overwrite (default: true)
# LOG_APPEND=true
# HWS Configuration
##################
# Host to listen on (default: 127.0.0.1)
# HWS_HOST=127.0.0.1
# Port to listen on (default: 3000)
HWS_PORT=3333
# Flag for GZIP compression on requests (default: false)
# HWS_GZIP=false
# Timeout for reading request headers in seconds (default: 2)
# HWS_READ_HEADER_TIMEOUT=2
# Timeout for writing requests in seconds (default: 10)
# HWS_WRITE_TIMEOUT=10
# Timeout for idle connections in seconds (default: 120)
# HWS_IDLE_TIMEOUT=120
# Delay in seconds before server shutsdown when Shutdown is called (default: 5)
# HWS_SHUTDOWN_DELAY=5
# HWSAuth Configuration
######################
# Enable SSL secure cookies (default: false)
# HWSAUTH_SSL=false
# Full server address for SSL (required)
HWSAUTH_TRUSTED_HOST=http://127.0.0.1:3000
# Secret key for signing JWT tokens (required)
HWSAUTH_SECRET_KEY=/2epovpAmHFwdmlCxHRnihT50ZQtrGF/wK7+wiJdFLI=
# Access token expiry in minutes (default: 5)
# HWSAUTH_ACCESS_TOKEN_EXPIRY=5
# Refresh token expiry in minutes (default: 1440)
# HWSAUTH_REFRESH_TOKEN_EXPIRY=1440
# Token fresh time in minutes (default: 5)
# HWSAUTH_TOKEN_FRESH_TIME=5
# Redirect destination for authenticated users (default: "/profile")
# HWSAUTH_LANDING_PAGE="/profile"
# Database type (postgres, mysql, sqlite, mariadb) (default: "postgres")
# HWSAUTH_DATABASE_TYPE="postgres"
# Database version string (default: "15")
HWSAUTH_DATABASE_VERSION=18
# Custom JWT blacklist table name (default: "jwtblacklist")
# HWSAUTH_JWT_TABLE_NAME="jwtblacklist"
# DB Configuration
#################
# Database user for authentication (required)
DB_USER=pgdev
# Database password for authentication (required)
DB_PASSWORD=pgdevuser
# Database host address (required)
DB_HOST=10.3.0.60
# Database port (default: 5432)
# DB_PORT=5432
# Database name to connect to (required)
DB_NAME=oslstats_test
# SSL mode for connection (default: disable)
# DB_SSL=disable
# Number of backups to keep (default: 10)
# DB_BACKUP_RETENTION=10
# Discord Configuration
######################
# Discord application client ID (required)
DISCORD_CLIENT_ID=1463459682235580499
# Discord application client secret (required)
DISCORD_CLIENT_SECRET=pinbGa9IkgYQfeBIfBuosor6ODK-JTON
# Path for the OAuth redirect handler (required)
DISCORD_REDIRECT_PATH=auth/callback
# Token for the discord bot (required)
DISCORD_BOT_TOKEN=MTQ2MzQ1OTY4MjIzNTU4MDQ5OQ.GK-9Q6.Z876_JG7oUIKFwKp5snxUjAzloxVjy7KP37TX4
# OAuth Configuration
####################
# Private key for signing OAuth state tokens (required)
OAUTH_PRIVATE_KEY=b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQyNTUxOQAAACDtDHHkeGp1POc0z6/vDj8SK48lVeuGswu/8UO4oBcYSAAAAJj7edqp+3naqQAAAAtzc2gtZWQyNTUxOQAAACDtDHHkeGp1POc0z6/vDj8SK48lVeuGswu/8UO4oBcYSAAAAEAuqALdQqnaDFb5PvuUN4ng1d191hsirOhnahsT0aJFV+0MceR4anU85zTPr+8OPxIrjyVV64azC7/xQ7igFxhIAAAAEWhhZWxub3JyQGZsYWdzaGlwAQIDBA==
# RBAC Configuration
###################
# Discord ID to grant admin role on first login (required)
ADMIN_DISCORD_ID=202990104170463241

View File

@@ -1,48 +0,0 @@
package main
import (
"database/sql"
"fmt"
"time"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
)
func setupBun(cfg *config.Config) (conn *bun.DB, close func() error) {
dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s",
cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.DB, cfg.DB.SSL)
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
sqldb.SetMaxOpenConns(25)
sqldb.SetMaxIdleConns(10)
sqldb.SetConnMaxLifetime(5 * time.Minute)
sqldb.SetConnMaxIdleTime(5 * time.Minute)
conn = bun.NewDB(sqldb, pgdialect.New())
registerDBModels(conn)
close = sqldb.Close
return conn, close
}
func registerDBModels(conn *bun.DB) []any {
models := []any{
(*db.RolePermission)(nil),
(*db.UserRole)(nil),
(*db.SeasonLeague)(nil),
(*db.TeamParticipation)(nil),
(*db.User)(nil),
(*db.DiscordToken)(nil),
(*db.Season)(nil),
(*db.League)(nil),
(*db.Team)(nil),
(*db.Role)(nil),
(*db.Permission)(nil),
(*db.AuditLog)(nil),
}
conn.RegisterModel(models...)
return models
}

View File

@@ -7,6 +7,7 @@ import (
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db/migrate"
"github.com/pkg/errors"
)
@@ -48,7 +49,7 @@ func main() {
// Handle migration file creation (doesn't need DB connection)
if flags.MigrateCreate != "" {
if err := createMigration(flags.MigrateCreate); err != nil {
if err := migrate.CreateMigration(flags.MigrateCreate); err != nil {
logger.Fatal().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "createMigration"))).Msg("Error creating migration")
}
return
@@ -59,17 +60,21 @@ func main() {
flags.MigrateStatus || flags.MigrateDryRun ||
flags.ResetDB {
var command, countStr string
// Route to appropriate command
if flags.MigrateUp != "" {
err = runMigrations(ctx, cfg, "up", flags.MigrateUp)
command = "up"
countStr = flags.MigrateUp
} else if flags.MigrateRollback != "" {
err = runMigrations(ctx, cfg, "rollback", flags.MigrateRollback)
command = "rollback"
countStr = flags.MigrateRollback
} else if flags.MigrateStatus {
err = runMigrations(ctx, cfg, "status", "")
} else if flags.MigrateDryRun {
err = runMigrations(ctx, cfg, "dry-run", "")
} else if flags.ResetDB {
err = resetDatabase(ctx, cfg)
command = "status"
}
if flags.ResetDB {
err = migrate.ResetDatabase(ctx, cfg)
} else {
err = migrate.RunMigrations(ctx, cfg, command, countStr)
}
if err != nil {

View File

@@ -1,280 +0,0 @@
package main
import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"github.com/pkg/errors"
"github.com/uptrace/bun"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/handlers"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/internal/store"
)
func addRoutes(
s *hws.Server,
staticFS *http.FileSystem,
cfg *config.Config,
conn *bun.DB,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
store *store.Store,
discordAPI *discord.APIClient,
perms *rbac.Checker,
audit *auditlog.Logger,
) error {
// Create the routes
pageroutes := []hws.Route{
{
Path: "/static/",
Method: hws.MethodGET,
Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, s)),
},
{
Path: "/",
Method: hws.MethodGET,
Handler: handlers.Index(s),
},
{
Path: "/login",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LogoutReq(handlers.Login(s, conn, cfg, store, discordAPI)),
},
{
Path: "/auth/callback",
Method: hws.MethodGET,
Handler: auth.LogoutReq(handlers.Callback(s, auth, conn, cfg, store, discordAPI)),
},
{
Path: "/register",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LogoutReq(handlers.Register(s, auth, conn, cfg, store)),
},
{
Path: "/logout",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)),
},
{
Path: "/notification-tester",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: perms.RequireAdmin(s)(handlers.NotifyTester(s)),
},
{
Path: "/seasons",
Method: hws.MethodGET,
Handler: handlers.SeasonsPage(s, conn),
},
{
Path: "/seasons",
Method: hws.MethodPOST,
Handler: handlers.SeasonsList(s, conn),
},
{
Path: "/seasons/new",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeason(s, conn)),
},
{
Path: "/seasons/new",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeasonSubmit(s, conn, audit)),
},
{
Path: "/seasons/{season_short_name}",
Method: hws.MethodGET,
Handler: handlers.SeasonPage(s, conn),
},
{
Path: "/seasons/{season_short_name}/edit",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditPage(s, conn)),
},
{
Path: "/seasons/{season_short_name}/edit",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn, audit)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}",
Method: hws.MethodGET,
Handler: handlers.SeasonLeaguePage(s, conn),
},
{
Path: "/seasons/{season_short_name}/leagues/add/{league_short_name}",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.SeasonsAddLeague)(handlers.SeasonAddLeague(s, conn, audit)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}",
Method: hws.MethodDELETE,
Handler: perms.RequirePermission(s, permissions.SeasonsRemoveLeague)(handlers.SeasonRemoveLeague(s, conn, audit)),
},
{
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/teams/add",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn, audit)),
},
{
Path: "/leagues",
Method: hws.MethodGET,
Handler: handlers.LeaguesList(s, conn),
},
{
Path: "/leagues/new",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeague(s, conn)),
},
{
Path: "/leagues/new",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.LeaguesCreate)(handlers.NewLeagueSubmit(s, conn, audit)),
},
{
Path: "/teams",
Method: hws.MethodGET,
Handler: handlers.TeamsPage(s, conn),
},
{
Path: "/teams",
Method: hws.MethodPOST,
Handler: handlers.TeamsList(s, conn),
},
{
Path: "/teams/new",
Method: hws.MethodGET,
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamPage(s, conn)),
},
{
Path: "/teams/new",
Method: hws.MethodPOST,
Handler: perms.RequirePermission(s, permissions.TeamsCreate)(handlers.NewTeamSubmit(s, conn, audit)),
},
}
htmxRoutes := []hws.Route{
{
Path: "/htmx/isusernameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.User)(nil), "username"),
},
{
Path: "/htmx/isseasonnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.Season)(nil), "name"),
},
{
Path: "/htmx/isseasonshortnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.Season)(nil), "short_name"),
},
{
Path: "/htmx/isleaguenameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "name"),
},
{
Path: "/htmx/isleagueshortnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.League)(nil), "short_name"),
},
{
Path: "/htmx/isteamnameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUnique(s, conn, (*db.Team)(nil), "name"),
},
{
Path: "/htmx/isteamshortnamesunique",
Method: hws.MethodPOST,
Handler: handlers.IsTeamShortNamesUnique(s, conn),
},
}
wsRoutes := []hws.Route{
{
Path: "/ws/notifications",
Method: hws.MethodGET,
Handler: handlers.NotificationWS(s, cfg),
},
}
// Admin routes
adminRoutes := []hws.Route{
// Full page routes (for direct navigation and refreshes)
{
Path: "/admin",
Method: hws.MethodGET,
Handler: perms.RequireAdmin(s)(handlers.AdminDashboard(s, conn)),
},
{
Path: "/admin/users",
Method: hws.MethodGET,
Handler: perms.RequireAdmin(s)(handlers.AdminUsersPage(s, conn)),
},
{
Path: "/admin/roles",
Method: hws.MethodGET,
Handler: perms.RequireAdmin(s)(handlers.AdminRolesPage(s, conn)),
},
{
Path: "/admin/permissions",
Method: hws.MethodGET,
Handler: perms.RequireAdmin(s)(handlers.AdminPermissionsPage(s, conn)),
},
{
Path: "/admin/audit",
Method: hws.MethodGET,
Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsPage(s, conn)),
},
// HTMX content fragment routes (for section swapping)
{
Path: "/admin/users",
Method: hws.MethodPOST,
Handler: perms.RequireAdmin(s)(handlers.AdminUsersList(s, conn)),
},
{
Path: "/admin/roles",
Method: hws.MethodPOST,
Handler: perms.RequireAdmin(s)(handlers.AdminRolesList(s, conn)),
},
{
Path: "/admin/permissions",
Method: hws.MethodPOST,
Handler: perms.RequireAdmin(s)(handlers.AdminPermissionsList(s, conn)),
},
{
Path: "/admin/audit",
Method: hws.MethodPOST,
Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsList(s, conn)),
},
// Audit log filtering (returns only results table, no URL push)
{
Path: "/admin/audit/filter",
Method: hws.MethodPOST,
Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsFilter(s, conn)),
},
// Audit log detail modal
{
Path: "/admin/audit/{id}",
Method: hws.MethodGET,
Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogDetail(s, conn)),
},
}
routes := append(pageroutes, htmxRoutes...)
routes = append(routes, wsRoutes...)
routes = append(routes, adminRoutes...)
// Register the routes with the server
err := s.AddRoutes(routes...)
if err != nil {
return errors.Wrap(err, "server.AddRoutes")
}
return nil
}

View File

@@ -12,9 +12,12 @@ import (
"github.com/pkg/errors"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/embedfs"
"git.haelnorr.com/h/oslstats/internal/server"
"git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
)
// Initializes and runs the server
@@ -25,8 +28,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
// Setup the database connection
logger.Debug().Msg("Config loaded and logger started")
logger.Debug().Msg("Connecting to database")
bun, closedb := setupBun(cfg)
// registerDBModels(bun)
conn := db.NewDB(cfg.DB)
// Setup embedded files
logger.Debug().Msg("Getting embedded files")
@@ -46,8 +48,15 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
return errors.Wrap(err, "discord.NewAPIClient")
}
// Setup Slapshot API
logger.Debug().Msg("Setting up Slapshot API client")
slapAPI, err := slapshotapi.NewSlapAPIClient(cfg.Slapshot)
if err != nil {
return errors.Wrap(err, "slapshotapi.NewSlapAPIClient")
}
logger.Debug().Msg("Setting up HTTP server")
httpServer, err := setupHTTPServer(&staticFS, cfg, logger, bun, store, discordAPI)
httpServer, err := server.Setup(staticFS, cfg, logger, conn, store, discordAPI, slapAPI)
if err != nil {
return errors.Wrap(err, "setupHttpServer")
}
@@ -71,7 +80,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
if err != nil {
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Error during HTTP server shutdown")
}
err = closedb()
err = conn.Close()
if err != nil {
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "closedb"))).Msg("Error during database close")
}

9
go.mod
View File

@@ -4,10 +4,10 @@ go 1.25.6
require (
git.haelnorr.com/h/golib/env v0.9.1
git.haelnorr.com/h/golib/ezconf v0.1.1
git.haelnorr.com/h/golib/hlog v0.10.4
git.haelnorr.com/h/golib/hws v0.5.0
git.haelnorr.com/h/golib/hwsauth v0.6.1
git.haelnorr.com/h/golib/ezconf v0.2.1
git.haelnorr.com/h/golib/hlog v0.11.0
git.haelnorr.com/h/golib/hws v0.6.0
git.haelnorr.com/h/golib/hwsauth v0.7.0
git.haelnorr.com/h/golib/notify v0.1.0
github.com/a-h/templ v0.3.977
github.com/coder/websocket v1.8.14
@@ -16,6 +16,7 @@ require (
github.com/uptrace/bun v1.2.16
github.com/uptrace/bun/dialect/pgdialect v1.2.16
github.com/uptrace/bun/driver/pgdriver v1.2.16
golang.org/x/time v0.14.0
)
require (

18
go.sum
View File

@@ -2,14 +2,14 @@ git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDO
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCIxTZOs=
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0=
git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
git.haelnorr.com/h/golib/ezconf v0.2.1 h1:axMyKtgO9Zk6E8CrYrLpMzifvpjz73yxCQq0lOtuhck=
git.haelnorr.com/h/golib/ezconf v0.2.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
git.haelnorr.com/h/golib/hlog v0.11.0 h1:tCT8HWs51Nbin58sCTLcq5re6CZqo5/IHCzk3G+S3vQ=
git.haelnorr.com/h/golib/hlog v0.11.0/go.mod h1:HjhXS5G3A0BwOZq7nu2qpNBtvOFiCa1GbAuBRxAkYqs=
git.haelnorr.com/h/golib/hws v0.6.0 h1:jwXUqT03PfrexVAC0xKVQWT2CLwN8+TDBsCK3+JWmEE=
git.haelnorr.com/h/golib/hws v0.6.0/go.mod h1:iAjdrwYZye2nsvbBGIMzVcfydV4F47qUp10MvimVCOY=
git.haelnorr.com/h/golib/hwsauth v0.7.0 h1:2uR7douZfkJ1vORUpvtS50FgGNm0GextDyMlCtrStbo=
git.haelnorr.com/h/golib/hwsauth v0.7.0/go.mod h1:VUHQwQiBy6BtY9Iyb+G3j/c/He9PmGNVDlDrWa7Id0U=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10=
@@ -90,6 +90,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,187 +0,0 @@
// Package auditlog provides a system for logging events that require permissions to the audit log
package auditlog
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Logger struct {
conn *bun.DB
}
func NewLogger(conn *bun.DB) *Logger {
return &Logger{conn: conn}
}
// LogSuccess logs a successful permission-protected action
func (l *Logger) LogSuccess(
ctx context.Context,
tx bun.Tx,
user *db.User,
action string,
resourceType string,
resourceID any, // Can be int, string, or nil
details map[string]any,
r *http.Request,
) error {
return l.log(ctx, tx, user, action, resourceType, resourceID, details, "success", nil, r)
}
// LogError logs a failed action due to an error
func (l *Logger) LogError(
ctx context.Context,
tx bun.Tx,
user *db.User,
action string,
resourceType string,
resourceID any,
err error,
r *http.Request,
) error {
errMsg := err.Error()
return l.log(ctx, tx, user, action, resourceType, resourceID, nil, "error", &errMsg, r)
}
func (l *Logger) log(
ctx context.Context,
tx bun.Tx,
user *db.User,
action string,
resourceType string,
resourceID any,
details map[string]any,
result string,
errorMessage *string,
r *http.Request,
) error {
if user == nil {
return errors.New("user cannot be nil for audit logging")
}
// Convert resourceID to string
var resourceIDStr *string
if resourceID != nil {
idStr := fmt.Sprintf("%v", resourceID)
resourceIDStr = &idStr
}
// Marshal details to JSON
var detailsJSON json.RawMessage
if details != nil {
jsonBytes, err := json.Marshal(details)
if err != nil {
return errors.Wrap(err, "json.Marshal details")
}
detailsJSON = jsonBytes
}
// Extract IP and User-Agent from request
ipAddress := r.RemoteAddr
userAgent := r.UserAgent()
log := &db.AuditLog{
UserID: user.ID,
Action: action,
ResourceType: resourceType,
ResourceID: resourceIDStr,
Details: detailsJSON,
IPAddress: ipAddress,
UserAgent: userAgent,
Result: result,
ErrorMessage: errorMessage,
CreatedAt: time.Now().Unix(),
}
return db.CreateAuditLog(ctx, tx, log)
}
// GetRecentLogs retrieves recent audit logs with pagination
func (l *Logger) GetRecentLogs(ctx context.Context, pageOpts *db.PageOpts) (*db.List[db.AuditLog], error) {
var logs *db.List[db.AuditLog]
if err := db.WithTxFailSilently(ctx, l.conn, func(ctx context.Context, tx bun.Tx) error {
var err error
logs, err = db.GetAuditLogs(ctx, tx, pageOpts, nil)
if err != nil {
return errors.Wrap(err, "db.GetAuditLogs")
}
return nil
}); err != nil {
return nil, errors.Wrap(err, "db.WithTxFailSilently")
}
return logs, nil
}
// GetLogsByUser retrieves audit logs for a specific user
func (l *Logger) GetLogsByUser(ctx context.Context, userID int, pageOpts *db.PageOpts) (*db.List[db.AuditLog], error) {
var logs *db.List[db.AuditLog]
if err := db.WithTxFailSilently(ctx, l.conn, func(ctx context.Context, tx bun.Tx) error {
var err error
logs, err = db.GetAuditLogsByUser(ctx, tx, userID, pageOpts)
if err != nil {
return errors.Wrap(err, "db.GetAuditLogsByUser")
}
return nil
}); err != nil {
return nil, errors.Wrap(err, "db.WithTxFailSilently")
}
return logs, nil
}
// CleanupOldLogs deletes audit logs older than the specified number of days
func (l *Logger) CleanupOldLogs(ctx context.Context, daysToKeep int) (int, error) {
if daysToKeep <= 0 {
return 0, errors.New("daysToKeep must be positive")
}
cutoffTime := time.Now().AddDate(0, 0, -daysToKeep).Unix()
var count int
if err := db.WithTxFailSilently(ctx, l.conn, func(ctx context.Context, tx bun.Tx) error {
var err error
count, err = db.CleanupOldAuditLogs(ctx, tx, cutoffTime)
if err != nil {
return errors.Wrap(err, "db.CleanupOldAuditLogs")
}
return nil
}); err != nil {
return 0, errors.Wrap(err, "db.WithTxFailSilently")
}
return count, nil
}
// Callback returns a db.AuditCallback that logs to this Logger
// This is used with the generic database helpers (Insert, Update, Delete)
//
// Usage:
//
// audit := auditlog.NewLogger(conn)
// err := db.Insert(tx, season).
// WithAudit(r, audit.Callback()).
// Exec(ctx)
func (l *Logger) Callback() db.AuditCallback {
return func(ctx context.Context, tx bun.Tx, info *db.AuditInfo, r *http.Request) error {
user := db.CurrentUser(ctx)
if user == nil {
return errors.New("no user in context for audit logging")
}
return l.LogSuccess(
ctx,
tx,
user,
info.Action,
info.ResourceType,
info.ResourceID,
info.Details,
r,
)
}
}

View File

@@ -10,19 +10,21 @@ import (
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/pkg/oauth"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/joho/godotenv"
"github.com/pkg/errors"
)
type Config struct {
DB *db.Config
HWS *hws.Config
HWSAuth *hwsauth.Config
HLOG *hlog.Config
Discord *discord.Config
OAuth *oauth.Config
RBAC *rbac.Config
Flags *Flags
DB *db.Config
HWS *hws.Config
HWSAuth *hwsauth.Config
HLOG *hlog.Config
Discord *discord.Config
OAuth *oauth.Config
RBAC *rbac.Config
Slapshot *slapshotapi.Config
Flags *Flags
}
// GetConfig loads the application configuration and returns a pointer to the Config object
@@ -34,7 +36,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
}
loader := ezconf.New()
err = loader.RegisterIntegrations(
err = loader.AddIntegrations(
hlog.NewEZConfIntegration(),
hws.NewEZConfIntegration(),
hwsauth.NewEZConfIntegration(),
@@ -42,6 +44,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
discord.NewEZConfIntegration(),
oauth.NewEZConfIntegration(),
rbac.NewEZConfIntegration(),
slapshotapi.NewEZConfIntegration(),
)
if err != nil {
return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations")
@@ -93,15 +96,21 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
return nil, nil, errors.New("RBAC Config not loaded")
}
slapcfg, ok := loader.GetConfig("slapshotapi")
if !ok {
return nil, nil, errors.New("SlapshotAPI Config not loaded")
}
config := &Config{
DB: dbcfg.(*db.Config),
HWS: hwscfg.(*hws.Config),
HWSAuth: hwsauthcfg.(*hwsauth.Config),
HLOG: hlogcfg.(*hlog.Config),
Discord: discordcfg.(*discord.Config),
OAuth: oauthcfg.(*oauth.Config),
RBAC: rbaccfg.(*rbac.Config),
Flags: flags,
DB: dbcfg.(*db.Config),
HWS: hwscfg.(*hws.Config),
HWSAuth: hwsauthcfg.(*hwsauth.Config),
HLOG: hlogcfg.(*hlog.Config),
Discord: discordcfg.(*discord.Config),
OAuth: oauthcfg.(*oauth.Config),
RBAC: rbaccfg.(*rbac.Config),
Slapshot: slapcfg.(*slapshotapi.Config),
Flags: flags,
}
return config, loader, nil

View File

@@ -10,4 +10,5 @@ func (c Key) String() string {
var (
DevModeKey Key = Key("devmode")
PermissionCacheKey Key = Key("permissions")
PreviewRoleKey Key = Key("preview-role")
)

View File

@@ -0,0 +1,25 @@
package contexts
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
)
// WithPreviewRole adds a preview role to the context
func WithPreviewRole(ctx context.Context, role *db.Role) context.Context {
return context.WithValue(ctx, PreviewRoleKey, role)
}
// GetPreviewRole retrieves the preview role from the context, or nil if not present
func GetPreviewRole(ctx context.Context) *db.Role {
if role, ok := ctx.Value(PreviewRoleKey).(*db.Role); ok {
return role
}
return nil
}
// IsPreviewMode returns true if the user is currently in preview mode
func IsPreviewMode(ctx context.Context) bool {
return GetPreviewRole(ctx) != nil
}

View File

@@ -1,25 +1,34 @@
package db
import (
"context"
"net/http"
"reflect"
"strings"
"github.com/uptrace/bun"
)
type AuditMeta struct {
ipAddress string
userAgent string
u *User
}
func NewAudit(ipAdd, agent string, user *User) *AuditMeta {
return &AuditMeta{ipAdd, agent, user}
}
func NewAuditFromRequest(r *http.Request) *AuditMeta {
u := CurrentUser(r.Context())
return &AuditMeta{r.Header.Get("X-Forwarded-For"), r.UserAgent(), u}
}
// 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
}
// AuditCallback is called after successful database operations to log changes
type AuditCallback func(ctx context.Context, tx bun.Tx, info *AuditInfo, r *http.Request) error
// extractTableName gets the bun table name from a model type using reflection
// Example: Season with `bun:"table:seasons,alias:s"` returns "seasons"
func extractTableName[T any]() string {
@@ -27,7 +36,7 @@ func extractTableName[T any]() string {
t := reflect.TypeOf(model)
// Handle pointer types
if t.Kind() == reflect.Ptr {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
@@ -38,10 +47,43 @@ func extractTableName[T any]() string {
bunTag := field.Tag.Get("bun")
if bunTag != "" {
// Parse tag: "table:seasons,alias:s" -> "seasons"
parts := strings.Split(bunTag, ",")
for _, part := range parts {
if strings.HasPrefix(part, "table:") {
return strings.TrimPrefix(part, "table:")
for part := range strings.SplitSeq(bunTag, ",") {
part, match := strings.CutPrefix(part, "table:")
if match {
return part
}
return part
}
}
}
}
// Fallback: use struct name in lowercase + "s"
return strings.ToLower(t.Name()) + "s"
}
// extractTableName gets the bun table alias from a model type using reflection
// Example: Season with `bun:"table:seasons,alias:s"` returns "s"
func extractTableAlias[T any]() string {
var model T
t := reflect.TypeOf(model)
// Handle pointer types
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
// Look for bun.BaseModel field with table tag
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Type.Name() == "BaseModel" {
bunTag := field.Tag.Get("bun")
if bunTag != "" {
// Parse tag: "table:seasons,alias:s" -> "seasons"
for part := range strings.SplitSeq(bunTag, ",") {
part, match := strings.CutPrefix(part, "alias:")
if match {
return part
}
}
}
@@ -81,7 +123,7 @@ func extractPrimaryKey[T any](model *T) any {
}
v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
@@ -110,7 +152,7 @@ func extractChangedFields[T any](model *T, columns []string) map[string]any {
result := make(map[string]any)
v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
@@ -142,5 +184,3 @@ func extractChangedFields[T any](model *T, columns []string) map[string]any {
return result
}
// Note: We don't need getTxFromQuery since we store the tx directly in our helper structs

View File

@@ -69,6 +69,34 @@ func (a *AuditLogFilter) Result(result string) *AuditLogFilter {
return a
}
func (a *AuditLogFilter) UserIDs(ids []int) *AuditLogFilter {
if len(ids) > 0 {
a.In("al.user_id", ids)
}
return a
}
func (a *AuditLogFilter) Actions(actions []string) *AuditLogFilter {
if len(actions) > 0 {
a.In("al.action", actions)
}
return a
}
func (a *AuditLogFilter) ResourceTypes(resourceTypes []string) *AuditLogFilter {
if len(resourceTypes) > 0 {
a.In("al.resource_type", resourceTypes)
}
return a
}
func (a *AuditLogFilter) Results(results []string) *AuditLogFilter {
if len(results) > 0 {
a.In("al.result", results)
}
return a
}
func (a *AuditLogFilter) DateRange(start, end int64) *AuditLogFilter {
if start > 0 {
a.GreaterEqualThan("al.created_at", start)
@@ -83,7 +111,7 @@ func (a *AuditLogFilter) DateRange(start, end int64) *AuditLogFilter {
func GetAuditLogs(ctx context.Context, tx bun.Tx, pageOpts *PageOpts, filters *AuditLogFilter) (*List[AuditLog], error) {
defaultPageOpts := &PageOpts{
Page: 1,
PerPage: 15,
PerPage: 10,
Order: bun.OrderDesc,
OrderBy: "created_at",
}
@@ -119,7 +147,7 @@ func GetAuditLogByID(ctx context.Context, tx bun.Tx, id int) (*AuditLog, error)
if id <= 0 {
return nil, errors.New("id must be positive")
}
return GetByID[AuditLog](tx, id).Relation("User").Get(ctx)
return GetByField[AuditLog](tx, "al.id", id).Relation("User").Get(ctx)
}
// GetUniqueActions retrieves a list of all unique actions in the audit log

View File

@@ -0,0 +1,84 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// LogSuccess logs a successful permission-protected action
func LogSuccess(
ctx context.Context,
tx bun.Tx,
meta *AuditMeta,
info *AuditInfo,
) error {
return log(ctx, tx, meta, info, "success", nil)
}
// LogError logs a failed action due to an error
func LogError(
ctx context.Context,
tx bun.Tx,
meta *AuditMeta,
info *AuditInfo,
err error,
) error {
errMsg := err.Error()
return log(ctx, tx, meta, info, "error", &errMsg)
}
func log(
ctx context.Context,
tx bun.Tx,
meta *AuditMeta,
info *AuditInfo,
result string,
errorMessage *string,
) error {
if meta == nil {
return errors.New("audit meta cannot be nil for audit logging")
}
if info == nil {
return errors.New("audit info cannot be nil for audit logging")
}
if meta.u == nil {
return errors.New("user cannot be nil for audit logging")
}
// Convert resourceID to string
var resourceIDStr *string
if info.ResourceID != nil {
idStr := fmt.Sprintf("%v", info.ResourceID)
resourceIDStr = &idStr
}
// Marshal details to JSON
var detailsJSON json.RawMessage
if info.Details != nil {
jsonBytes, err := json.Marshal(info.Details)
if err != nil {
return errors.Wrap(err, "json.Marshal details")
}
detailsJSON = jsonBytes
}
log := &AuditLog{
UserID: meta.u.ID,
Action: info.Action,
ResourceType: info.ResourceType,
ResourceID: resourceIDStr,
Details: detailsJSON,
IPAddress: meta.ipAddress,
UserAgent: meta.userAgent,
Result: result,
ErrorMessage: errorMessage,
CreatedAt: time.Now().Unix(),
}
return CreateAuditLog(ctx, tx, log)
}

View File

@@ -1,4 +1,4 @@
package backup
package db
import (
"context"
@@ -9,14 +9,13 @@ import (
"sort"
"time"
"git.haelnorr.com/h/oslstats/internal/config"
"github.com/pkg/errors"
)
// CreateBackup creates a compressed PostgreSQL dump before migrations
// Returns backup filename and error
// If pg_dump is not available, returns nil error with warning
func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (string, error) {
func CreateBackup(ctx context.Context, cfg *Config, operation string) (string, error) {
// Check if pg_dump is available
if _, err := exec.LookPath("pg_dump"); err != nil {
fmt.Println("[WARN] pg_dump not found - skipping backup")
@@ -28,13 +27,13 @@ func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (st
}
// Ensure backup directory exists
if err := os.MkdirAll(cfg.DB.BackupDir, 0755); err != nil {
if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil {
return "", errors.Wrap(err, "failed to create backup directory")
}
// Generate filename: YYYYMMDD_HHmmss_pre_{operation}.sql.gz
timestamp := time.Now().Format("20060102_150405")
filename := filepath.Join(cfg.DB.BackupDir,
filename := filepath.Join(cfg.BackupDir,
fmt.Sprintf("%s_pre_%s.sql.gz", timestamp, operation))
// Check if gzip is available
@@ -42,7 +41,7 @@ func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (st
if _, err := exec.LookPath("gzip"); err != nil {
fmt.Println("[WARN] gzip not found - using uncompressed backup")
useGzip = false
filename = filepath.Join(cfg.DB.BackupDir,
filename = filepath.Join(cfg.BackupDir,
fmt.Sprintf("%s_pre_%s.sql", timestamp, operation))
}
@@ -52,19 +51,19 @@ func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (st
// Use shell to pipe pg_dump through gzip
pgDumpCmd := fmt.Sprintf(
"pg_dump -h %s -p %d -U %s -d %s --no-owner --no-acl --clean --if-exists | gzip > %s",
cfg.DB.Host,
cfg.DB.Port,
cfg.DB.User,
cfg.DB.DB,
cfg.Host,
cfg.Port,
cfg.User,
cfg.DB,
filename,
)
cmd = exec.CommandContext(ctx, "sh", "-c", pgDumpCmd)
} else {
cmd = exec.CommandContext(ctx, "pg_dump",
"-h", cfg.DB.Host,
"-p", fmt.Sprint(cfg.DB.Port),
"-U", cfg.DB.User,
"-d", cfg.DB.DB,
"-h", cfg.Host,
"-p", fmt.Sprint(cfg.Port),
"-U", cfg.User,
"-d", cfg.DB,
"-f", filename,
"--no-owner",
"--no-acl",
@@ -75,7 +74,7 @@ func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (st
// Set password via environment variable
cmd.Env = append(os.Environ(),
fmt.Sprintf("PGPASSWORD=%s", cfg.DB.Password))
fmt.Sprintf("PGPASSWORD=%s", cfg.Password))
// Run backup
if err := cmd.Run(); err != nil {
@@ -95,14 +94,14 @@ func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (st
}
// CleanOldBackups keeps only the N most recent backups
func CleanOldBackups(cfg *config.Config, keepCount int) error {
func CleanOldBackups(cfg *Config, keepCount int) error {
// Get all backup files (both .sql and .sql.gz)
sqlFiles, err := filepath.Glob(filepath.Join(cfg.DB.BackupDir, "*.sql"))
sqlFiles, err := filepath.Glob(filepath.Join(cfg.BackupDir, "*.sql"))
if err != nil {
return errors.Wrap(err, "failed to list .sql backups")
}
gzFiles, err := filepath.Glob(filepath.Join(cfg.DB.BackupDir, "*.sql.gz"))
gzFiles, err := filepath.Glob(filepath.Join(cfg.BackupDir, "*.sql.gz"))
if err != nil {
return errors.Wrap(err, "failed to list .sql.gz backups")
}

View File

@@ -6,16 +6,14 @@ import (
)
type Config struct {
User string // ENV DB_USER: Database user for authentication (required)
Password string // ENV DB_PASSWORD: Database password for authentication (required)
Host string // ENV DB_HOST: Database host address (required)
Port uint16 // ENV DB_PORT: Database port (default: 5432)
DB string // ENV DB_NAME: Database name to connect to (required)
SSL string // ENV DB_SSL: SSL mode for connection (default: disable)
// Backup configuration
BackupDir string // ENV DB_BACKUP_DIR: Directory for database backups (default: backups)
BackupRetention int // ENV DB_BACKUP_RETENTION: Number of backups to keep (default: 10)
User string `ezconf:"DB_USER,required,description:Database user for authentication"`
Password string `ezconf:"DB_PASSWORD,required,description:Database password for authentication"`
Host string `ezconf:"DB_HOST,required,description:Database host address"`
Port uint16 `ezconf:"DB_PORT,default:5432,description:Database port"`
DB string `ezconf:"DB_NAME,required,description:Database to connect to"`
SSL string `ezconf:"DB_SSL,default:disable,description:SSL Mode"`
BackupDir string `ezconf:"DB_BACKUP_DIR,default:backups,description:Directory for database backups"`
BackupRetention int `ezconf:"DB_BACKUP_RETENTION,default:10,description:Number of backups to keep"`
}
func ConfigFromEnv() (any, error) {

View File

@@ -2,19 +2,17 @@ package db
import (
"context"
"database/sql"
"net/http"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type deleter[T any] struct {
tx bun.Tx
q *bun.DeleteQuery
resourceID any // Store ID before deletion for audit
auditCallback AuditCallback
auditRequest *http.Request
tx bun.Tx
q *bun.DeleteQuery
resourceID any // Store ID before deletion for audit
audit *AuditMeta
auditInfo *AuditInfo
}
type systemType interface {
@@ -39,39 +37,45 @@ func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
}
// WithAudit enables audit logging for this delete operation
// The callback will be invoked after successful deletion with auto-generated audit info
// If the callback returns an error, the transaction will be rolled back
func (d *deleter[T]) WithAudit(r *http.Request, callback AuditCallback) *deleter[T] {
d.auditRequest = r
d.auditCallback = callback
// If the provided *AuditInfo is nil, will use reflection to automatically work out the details
func (d *deleter[T]) WithAudit(meta *AuditMeta, info *AuditInfo) *deleter[T] {
d.audit = meta
d.auditInfo = info
return d
}
func (d *deleter[T]) Delete(ctx context.Context) error {
_, err := d.q.Exec(ctx)
result, err := d.q.Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
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
if d.auditCallback != nil && d.auditRequest != nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "delete")
if d.audit != nil {
if d.auditInfo == nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "delete")
info := &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: d.resourceID,
Details: nil, // Delete doesn't need details
d.auditInfo = &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: d.resourceID,
Details: nil, // Delete doesn't need details
}
}
// Call audit callback - if it fails, return error to trigger rollback
if err := d.auditCallback(ctx, d.tx, info, d.auditRequest); err != nil {
return errors.Wrap(err, "audit.callback")
err = LogSuccess(ctx, d.tx, d.audit, d.auditInfo)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
}
@@ -82,17 +86,17 @@ func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {
return DeleteItem[T](tx).Where("id = ?", id)
}
func DeleteWithProtection[T systemType](ctx context.Context, tx bun.Tx, id int) error {
func DeleteWithProtection[T systemType](ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error {
deleter := DeleteByID[T](tx, id)
item, err := GetByID[T](tx, id).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if item == nil {
return errors.New("record not found")
}
if (*item).isSystem() {
return errors.New("record is system protected")
}
if audit != nil {
deleter = deleter.WithAudit(audit, nil)
}
return deleter.Delete(ctx)
}

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) {
token, err := u.GetDiscordToken(ctx, tx)
if err != nil {
if IsBadRequest(err) {
return nil, nil // Token doesn't exist - not an error
}
return nil, errors.Wrap(err, "user.GetDiscordToken")
}
if token == nil {
return nil, nil
}
_, err = tx.NewDelete().
Model((*DiscordToken)(nil)).
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)
}

View File

@@ -1,41 +1,11 @@
package db
import (
"runtime"
"strings"
"git.haelnorr.com/h/golib/ezconf"
)
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct {
configFunc func() (any, error)
name string
}
// PackagePath returns the path to the config package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return e.configFunc()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return strings.ToLower(e.name)
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return e.name
}
// NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration {
return EZConfIntegration{name: "DB", configFunc: ConfigFromEnv}
func NewEZConfIntegration() *ezconf.Integration {
return ezconf.NewIntegration("db", "DB", &Config{},
func() (any, error) { return ConfigFromEnv() })
}

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

@@ -0,0 +1,380 @@
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"`
Schedules []*FixtureSchedule `bun:"rel:has-many,join:id=fixture_id"`
}
// CanSchedule checks if the user is a manager of one of the teams in the fixture.
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
func (f *Fixture) CanSchedule(ctx context.Context, tx bun.Tx, user *User) (bool, int, error) {
if user == nil || user.Player == nil {
return false, 0, nil
}
roster := new(TeamRoster)
err := tx.NewSelect().
Model(roster).
Column("team_id", "is_manager").
Where("team_id IN (?)", bun.In([]int{f.HomeTeamID, f.AwayTeamID})).
Where("season_id = ?", f.SeasonID).
Where("league_id = ?", f.LeagueID).
Where("player_id = ?", user.Player.ID).
Scan(ctx)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return false, 0, nil
}
return false, 0, errors.Wrap(err, "tx.NewSelect")
}
if !roster.IsManager {
return false, 0, nil
}
return true, roster.TeamID, nil
}
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
homeTeamID, awayTeamID, round int, audit *AuditMeta,
) (*Fixture, error) {
season, league, teams, err := GetSeasonLeagueWithTeams(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 := GetSeasonLeagueWithTeams(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) (*SeasonLeague, []*Fixture, error) {
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, nil, errors.Wrap(err, "GetSeasonLeague")
}
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", sl.SeasonID).
Where("league_id = ?", sl.LeagueID).
Order("game_week ASC NULLS FIRST", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "GetList")
}
return sl, 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)
}
// GetAllocatedFixtures returns all fixtures with a game_week assigned for a season+league.
func GetAllocatedFixtures(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NOT NULL").
Order("game_week ASC", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func GetFixturesForTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("game_week IS NOT NULL").
Where("(home_team_id = ? OR away_team_id = ?)", teamID, teamID).
Order("game_week ASC", "round ASC", "id ASC").
Relation("HomeTeam").
Relation("AwayTeam").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return fixtures, nil
}
func GetFixturesByGameWeek(ctx context.Context, tx bun.Tx, seasonID, leagueID, gameweek int) ([]*Fixture, error) {
fixtures, err := GetList[Fixture](tx).
Where("season_id = ?", seasonID).
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 {
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return errors.Wrap(err, "GetSeasonLeague")
}
err = DeleteItem[Fixture](tx).
Where("season_id = ?", sl.SeasonID).
Where("league_id = ?", sl.LeagueID).
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+round)%2 == 0 {
versus.homeTeam = team
versus.awayTeam = opponent
} else {
versus.homeTeam = opponent
versus.awayTeam = team
}
matchups[i] = versus
}
return matchups
}
func AutoAllocateFixtures(fixtures []*Fixture, gamesPerWeek, startingWeek int) []*Fixture {
gameWeek := startingWeek
teamPlays := map[int]int{}
// Work on a copy so we can track what's remaining
remaining := make([]*Fixture, len(fixtures))
copy(remaining, fixtures)
for len(remaining) > 0 {
madeProgress := false
nextRemaining := make([]*Fixture, 0, len(remaining))
for _, fixture := range remaining {
if teamPlays[fixture.HomeTeamID] < gamesPerWeek &&
teamPlays[fixture.AwayTeamID] < gamesPerWeek {
gw := gameWeek
fixture.GameWeek = &gw
teamPlays[fixture.HomeTeamID]++
teamPlays[fixture.AwayTeamID]++
madeProgress = true
} else {
nextRemaining = append(nextRemaining, fixture)
}
}
if !madeProgress {
// No fixture could be placed this week — advance to avoid infinite loop
// (shouldn't happen with valid fixture data, but guards against edge cases)
gameWeek++
teamPlays = map[int]int{}
continue
}
remaining = nextRemaining
if len(remaining) > 0 {
gameWeek++
teamPlays = map[int]int{}
}
}
return fixtures
}

View File

@@ -0,0 +1,587 @@
package db
import (
"context"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type FixtureResult struct {
bun.BaseModel `bun:"table:fixture_results,alias:fr"`
ID int `bun:"id,pk,autoincrement"`
FixtureID int `bun:",notnull,unique"`
Winner string `bun:",notnull"`
HomeScore int `bun:",notnull"`
AwayScore int `bun:",notnull"`
MatchType string
Arena string
EndReason string
PeriodsEnabled bool
CustomMercyRule int
MatchLength int
CreatedAt int64 `bun:",notnull"`
UpdatedAt *int64
UploadedByUserID int `bun:",notnull"`
Finalized bool `bun:",default:false"`
TamperingDetected bool `bun:",default:false"`
TamperingReason *string
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
}
type FixtureResultPlayerStats struct {
bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"`
ID int `bun:"id,pk,autoincrement"`
FixtureResultID int `bun:",notnull"`
PeriodNum int `bun:",notnull"`
PlayerID *int // NULL for unmapped/free agents
PlayerGameUserID string `bun:",notnull"`
PlayerUsername string `bun:",notnull"`
TeamID *int // NULL for unmapped
Team string `bun:",notnull"` // 'home' or 'away'
// All stats as INT (nullable)
Goals *int
Assists *int
PrimaryAssists *int
SecondaryAssists *int
Saves *int
Blocks *int
Shots *int
Turnovers *int
Takeaways *int
Passes *int
PossessionTimeSec *int
FaceoffsWon *int
FaceoffsLost *int
PostHits *int
OvertimeGoals *int
GameWinningGoals *int
Score *int
ContributedGoals *int
ConcededGoals *int
GamesPlayed *int
Wins *int
Losses *int
OvertimeWins *int
OvertimeLosses *int
Ties *int
Shutouts *int
ShutoutsAgainst *int
HasMercyRuled *int
WasMercyRuled *int
PeriodsPlayed *int
IsFreeAgent bool `bun:"is_free_agent,default:false"`
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"`
}
// PlayerWithPlayStatus is a helper struct for overview display
type PlayerWithPlayStatus struct {
Player *Player
Played bool
IsManager bool
IsFreeAgent bool
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
}
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
func InsertFixtureResult(
ctx context.Context,
tx bun.Tx,
result *FixtureResult,
playerStats []*FixtureResultPlayerStats,
audit *AuditMeta,
) (*FixtureResult, error) {
if result == nil {
return nil, errors.New("result cannot be nil")
}
result.CreatedAt = time.Now().Unix()
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
Action: "fixture_results.create",
ResourceType: "fixture_result",
ResourceID: nil,
Details: map[string]any{
"fixture_id": result.FixtureID,
"winner": result.Winner,
"home_score": result.HomeScore,
"away_score": result.AwayScore,
"tampering_detected": result.TamperingDetected,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert result")
}
// Set the fixture_result_id on all player stats
for _, ps := range playerStats {
ps.FixtureResultID = result.ID
}
// Insert player stats in bulk
if len(playerStats) > 0 {
err = InsertMultiple(tx, playerStats).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "InsertMultiple player stats")
}
}
return result, nil
}
// GetFixtureResult retrieves a result with all player stats for a fixture.
// Returns nil, nil if no result exists.
func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
result := new(FixtureResult)
err := tx.NewSelect().
Model(result).
Where("fr.fixture_id = ?", fixtureID).
Relation("Fixture").
Relation("UploadedBy").
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
}).
Relation("PlayerStats.Player").
Scan(ctx)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return result, nil
}
// GetPendingFixtureResult retrieves a non-finalized result for review/edit.
// Returns nil, nil if no pending result exists.
func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
result := new(FixtureResult)
err := tx.NewSelect().
Model(result).
Where("fr.fixture_id = ?", fixtureID).
Where("fr.finalized = false").
Relation("Fixture").
Relation("UploadedBy").
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
}).
Relation("PlayerStats.Player").
Scan(ctx)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return result, nil
}
// FinalizeFixtureResult marks a pending result as finalized.
func FinalizeFixtureResult(
ctx context.Context,
tx bun.Tx,
fixtureID int,
audit *AuditMeta,
) error {
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetPendingFixtureResult")
}
if result == nil {
return BadRequest("no pending result to finalize")
}
now := time.Now().Unix()
result.Finalized = true
result.UpdatedAt = &now
err = UpdateByID(tx, result.ID, result).
Column("finalized", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_results.finalize",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": fixtureID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// DeleteFixtureResult removes a pending result and all associated player stats (CASCADE).
func DeleteFixtureResult(
ctx context.Context,
tx bun.Tx,
fixtureID int,
audit *AuditMeta,
) error {
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetPendingFixtureResult")
}
if result == nil {
return BadRequest("no pending result to discard")
}
err = DeleteByID[FixtureResult](tx, result.ID).
WithAudit(audit, &AuditInfo{
Action: "fixture_results.discard",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": fixtureID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID")
}
return nil
}
// GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs.
// Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency).
func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) {
if len(fixtureIDs) == 0 {
return map[int]*FixtureResult{}, nil
}
results, err := GetList[FixtureResult](tx).
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
Where("finalized = true").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
resultMap := make(map[int]*FixtureResult, len(results))
for _, r := range results {
resultMap[r.FixtureID] = r
}
return resultMap, nil
}
// AggregatedPlayerStats holds summed stats for a player across multiple fixtures.
type AggregatedPlayerStats struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
GamesPlayed int `bun:"games_played"`
Score int `bun:"total_score"`
Goals int `bun:"total_goals"`
Assists int `bun:"total_assists"`
Saves int `bun:"total_saves"`
Shots int `bun:"total_shots"`
Blocks int `bun:"total_blocks"`
Passes int `bun:"total_passes"`
}
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
// players on a given team across all finalized fixture results.
func GetAggregatedPlayerStatsForTeam(
ctx context.Context,
tx bun.Tx,
teamID int,
fixtureIDs []int,
) ([]*AggregatedPlayerStats, error) {
if len(fixtureIDs) == 0 {
return nil, nil
}
var stats []*AggregatedPlayerStats
err := tx.NewRaw(`
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
COALESCE(SUM(frps.score), 0) AS total_score,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.shots), 0) AS total_shots,
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
COALESCE(SUM(frps.passes), 0) AS total_passes
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND fr.fixture_id IN (?)
AND frps.team_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_score DESC
`, bun.In(fixtureIDs), teamID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// TeamRecord holds win/loss/draw record and goal totals for a team.
type TeamRecord struct {
Played int
Wins int
OvertimeWins int
OvertimeLosses int
Losses int
Draws int
GoalsFor int
GoalsAgainst int
Points int
}
// Point values for the leaderboard scoring system.
const (
PointsWin = 3
PointsOvertimeWin = 2
PointsOvertimeLoss = 1
PointsLoss = 0
)
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
// Points: Win=3, OT Win=2, OT Loss=1, Loss=0.
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
rec := &TeamRecord{}
for _, f := range fixtures {
res, ok := resultMap[f.ID]
if !ok {
continue
}
rec.Played++
isHome := f.HomeTeamID == teamID
if isHome {
rec.GoalsFor += res.HomeScore
rec.GoalsAgainst += res.AwayScore
} else {
rec.GoalsFor += res.AwayScore
rec.GoalsAgainst += res.HomeScore
}
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
isOT := strings.EqualFold(res.EndReason, "Overtime")
switch {
case won && isOT:
rec.OvertimeWins++
rec.Points += PointsOvertimeWin
case won:
rec.Wins++
rec.Points += PointsWin
case lost && isOT:
rec.OvertimeLosses++
rec.Points += PointsOvertimeLoss
case lost:
rec.Losses++
rec.Points += PointsLoss
default:
rec.Draws++
}
}
return rec
}
// LeaderboardEntry represents a single team's standing in the league table.
type LeaderboardEntry struct {
Position int
Team *Team
Record *TeamRecord
}
// ComputeLeaderboard builds a sorted leaderboard from teams, fixtures, and results.
// Teams are sorted by: Points DESC, Goal Differential DESC, Goals For DESC, Name ASC.
func ComputeLeaderboard(teams []*Team, fixtures []*Fixture, resultMap map[int]*FixtureResult) []*LeaderboardEntry {
entries := make([]*LeaderboardEntry, 0, len(teams))
// Build a map of team ID -> fixtures involving that team
teamFixtures := make(map[int][]*Fixture)
for _, f := range fixtures {
teamFixtures[f.HomeTeamID] = append(teamFixtures[f.HomeTeamID], f)
teamFixtures[f.AwayTeamID] = append(teamFixtures[f.AwayTeamID], f)
}
for _, team := range teams {
record := ComputeTeamRecord(team.ID, teamFixtures[team.ID], resultMap)
entries = append(entries, &LeaderboardEntry{
Team: team,
Record: record,
})
}
// Sort: Points DESC, then goal diff DESC, then GF DESC, then name ASC
sort.Slice(entries, func(i, j int) bool {
ri, rj := entries[i].Record, entries[j].Record
if ri.Points != rj.Points {
return ri.Points > rj.Points
}
diffI := ri.GoalsFor - ri.GoalsAgainst
diffJ := rj.GoalsFor - rj.GoalsAgainst
if diffI != diffJ {
return diffI > diffJ
}
if ri.GoalsFor != rj.GoalsFor {
return ri.GoalsFor > rj.GoalsFor
}
return entries[i].Team.Name < entries[j].Team.Name
})
// Assign positions
for i := range entries {
entries[i].Position = i + 1
}
return entries
}
// GetFixtureTeamRosters returns all team players with participation status for a fixture.
// Returns: map["home"|"away"] -> []*PlayerWithPlayStatus
func GetFixtureTeamRosters(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
result *FixtureResult,
) (map[string][]*PlayerWithPlayStatus, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
rosters := map[string][]*PlayerWithPlayStatus{}
// Get home team roster
homeRosters := []*TeamRoster{}
err := tx.NewSelect().
Model(&homeRosters).
Where("tr.team_id = ?", fixture.HomeTeamID).
Where("tr.season_id = ?", fixture.SeasonID).
Where("tr.league_id = ?", fixture.LeagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect home roster")
}
// Get away team roster
awayRosters := []*TeamRoster{}
err = tx.NewSelect().
Model(&awayRosters).
Where("tr.team_id = ?", fixture.AwayTeamID).
Where("tr.season_id = ?", fixture.SeasonID).
Where("tr.league_id = ?", fixture.LeagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect away roster")
}
// Build maps of player IDs that played and their period 3 stats
playedPlayerIDs := map[int]bool{}
playerStatsByID := map[int]*FixtureResultPlayerStats{}
freeAgentPlayerIDs := map[int]bool{}
// Track free agents by team side for roster inclusion
freeAgentsByTeam := map[string]map[int]*FixtureResultPlayerStats{} // "home"/"away" -> playerID -> stats
freeAgentsByTeam["home"] = map[int]*FixtureResultPlayerStats{}
freeAgentsByTeam["away"] = map[int]*FixtureResultPlayerStats{}
if result != nil {
for _, ps := range result.PlayerStats {
if ps.PlayerID != nil {
playedPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
playerStatsByID[*ps.PlayerID] = ps
}
if ps.IsFreeAgent {
freeAgentPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
freeAgentsByTeam[ps.Team][*ps.PlayerID] = ps
}
}
}
}
}
// Build a set of roster player IDs so we can skip them when adding free agents
rosterPlayerIDs := map[int]bool{}
for _, r := range homeRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
for _, r := range awayRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
// Build home roster with play status and stats
for _, r := range homeRosters {
played := false
var stats *FixtureResultPlayerStats
if result != nil && r.Player != nil {
played = playedPlayerIDs[r.Player.ID]
stats = playerStatsByID[r.Player.ID]
}
rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{
Player: r.Player,
Played: played,
IsManager: r.IsManager,
Stats: stats,
})
}
// Build away roster with play status and stats
for _, r := range awayRosters {
played := false
var stats *FixtureResultPlayerStats
if result != nil && r.Player != nil {
played = playedPlayerIDs[r.Player.ID]
stats = playerStatsByID[r.Player.ID]
}
rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{
Player: r.Player,
Played: played,
IsManager: r.IsManager,
Stats: stats,
})
}
// Add free agents who played but are not on the team roster
for team, faStats := range freeAgentsByTeam {
for playerID, stats := range faStats {
if rosterPlayerIDs[playerID] {
continue // Already on the roster, skip
}
if stats.Player == nil {
// Try to load the player
player, err := GetPlayer(ctx, tx, playerID)
if err != nil {
continue // Skip if we can't load
}
stats.Player = player
}
rosters[team] = append(rosters[team], &PlayerWithPlayStatus{
Player: stats.Player,
Played: true,
IsManager: false,
IsFreeAgent: true,
Stats: stats,
})
}
}
return rosters, nil
}

View File

@@ -0,0 +1,426 @@
package db
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// ScheduleStatus represents the current status of a fixture schedule proposal
type ScheduleStatus string
const (
ScheduleStatusPending ScheduleStatus = "pending"
ScheduleStatusAccepted ScheduleStatus = "accepted"
ScheduleStatusRejected ScheduleStatus = "rejected"
ScheduleStatusRescheduled ScheduleStatus = "rescheduled"
ScheduleStatusPostponed ScheduleStatus = "postponed"
ScheduleStatusCancelled ScheduleStatus = "cancelled"
ScheduleStatusWithdrawn ScheduleStatus = "withdrawn"
)
// IsTerminal returns true if the status is a terminal (immutable) state
func (s ScheduleStatus) IsTerminal() bool {
switch s {
case ScheduleStatusRejected, ScheduleStatusRescheduled,
ScheduleStatusPostponed, ScheduleStatusCancelled,
ScheduleStatusWithdrawn:
return true
}
return false
}
// RescheduleReason represents the predefined reasons for rescheduling or postponing
type RescheduleReason string
const (
ReasonMutuallyAgreed RescheduleReason = "Mutually Agreed"
ReasonTeamUnavailable RescheduleReason = "Team Unavailable"
ReasonTeamNoShow RescheduleReason = "Team No-show"
)
type FixtureSchedule struct {
bun.BaseModel `bun:"table:fixture_schedules,alias:fs"`
ID int `bun:"id,pk,autoincrement"`
FixtureID int `bun:",notnull"`
ScheduledTime *time.Time `bun:"scheduled_time"`
ProposedByTeamID int `bun:",notnull"`
AcceptedByTeamID *int `bun:"accepted_by_team_id"`
Status ScheduleStatus `bun:",notnull,default:'pending'"`
RescheduleReason *string `bun:"reschedule_reason"`
CreatedAt int64 `bun:",notnull"`
UpdatedAt *int64 `bun:"updated_at"`
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"`
AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"`
}
// GetAcceptedSchedulesForFixtures returns the accepted schedule for each fixture in the given list.
// Returns a map of fixtureID -> *FixtureSchedule (only accepted schedules are included).
func GetAcceptedSchedulesForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureSchedule, error) {
if len(fixtureIDs) == 0 {
return map[int]*FixtureSchedule{}, nil
}
schedules, err := GetList[FixtureSchedule](tx).
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
Where("status = ?", ScheduleStatusAccepted).
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
result := make(map[int]*FixtureSchedule, len(schedules))
for _, s := range schedules {
// If multiple accepted exist (shouldn't happen), keep the most recent
existing, ok := result[s.FixtureID]
if !ok || s.CreatedAt > existing.CreatedAt {
result[s.FixtureID] = s
}
}
return result, nil
}
// GetFixtureScheduleHistory returns all schedule records for a fixture in chronological order
func GetFixtureScheduleHistory(ctx context.Context, tx bun.Tx, fixtureID int) ([]*FixtureSchedule, error) {
schedules, err := GetList[FixtureSchedule](tx).
Where("fixture_id = ?", fixtureID).
Order("created_at ASC", "id ASC").
Relation("ProposedBy").
Relation("AcceptedBy").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return schedules, nil
}
// GetCurrentFixtureSchedule returns the most recent schedule record for a fixture.
// Returns nil, nil if no schedule exists.
func GetCurrentFixtureSchedule(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureSchedule, error) {
schedule := new(FixtureSchedule)
err := tx.NewSelect().
Model(schedule).
Where("fixture_id = ?", fixtureID).
Order("created_at DESC", "id DESC").
Relation("ProposedBy").
Relation("AcceptedBy").
Limit(1).
Scan(ctx)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return schedule, nil
}
// ProposeFixtureSchedule creates a new pending schedule proposal for a fixture.
// If there is an existing pending record with no time (postponed placeholder), it will be
// superseded. Cannot propose on cancelled or accepted schedules.
func ProposeFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID, proposedByTeamID int,
scheduledTime time.Time,
audit *AuditMeta,
) (*FixtureSchedule, error) {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return nil, errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current != nil {
switch current.Status {
case ScheduleStatusCancelled:
return nil, BadRequest("cannot propose a new time for a cancelled fixture")
case ScheduleStatusAccepted:
return nil, BadRequest("fixture already has an accepted schedule; use reschedule instead")
case ScheduleStatusPending:
// Supersede existing pending record (e.g., postponed placeholder or old proposal)
now := time.Now().Unix()
current.Status = ScheduleStatusRescheduled
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "updated_at").
Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "UpdateByID")
}
// rejected, rescheduled, postponed are terminal — safe to create a new proposal
}
}
schedule := &FixtureSchedule{
FixtureID: fixtureID,
ScheduledTime: &scheduledTime,
ProposedByTeamID: proposedByTeamID,
Status: ScheduleStatusPending,
CreatedAt: time.Now().Unix(),
}
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.propose",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"proposed_by": proposedByTeamID,
"scheduled_time": scheduledTime,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return schedule, nil
}
// AcceptFixtureSchedule accepts a pending schedule proposal.
// The acceptedByTeamID must be the other team (not the proposer).
func AcceptFixtureSchedule(
ctx context.Context,
tx bun.Tx,
scheduleID, acceptedByTeamID int,
audit *AuditMeta,
) error {
schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if schedule.Status != ScheduleStatusPending {
return BadRequest("schedule is not in pending status")
}
if schedule.ProposedByTeamID == acceptedByTeamID {
return BadRequest("cannot accept your own proposal")
}
now := time.Now().Unix()
schedule.AcceptedByTeamID = &acceptedByTeamID
schedule.Status = ScheduleStatusAccepted
schedule.UpdatedAt = &now
err = UpdateByID(tx, schedule.ID, schedule).
Column("accepted_by_team_id", "status", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.accept",
ResourceType: "fixture_schedule",
ResourceID: scheduleID,
Details: map[string]any{
"fixture_id": schedule.FixtureID,
"accepted_by": acceptedByTeamID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// RejectFixtureSchedule rejects a pending schedule proposal.
func RejectFixtureSchedule(
ctx context.Context,
tx bun.Tx,
scheduleID int,
audit *AuditMeta,
) error {
schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if schedule.Status != ScheduleStatusPending {
return BadRequest("schedule is not in pending status")
}
now := time.Now().Unix()
schedule.Status = ScheduleStatusRejected
schedule.UpdatedAt = &now
err = UpdateByID(tx, schedule.ID, schedule).
Column("status", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.reject",
ResourceType: "fixture_schedule",
ResourceID: scheduleID,
Details: map[string]any{
"fixture_id": schedule.FixtureID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// RescheduleFixtureSchedule marks the current accepted schedule as rescheduled and creates
// a new pending proposal with the new time.
func RescheduleFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID, proposedByTeamID int,
newTime time.Time,
reason string,
audit *AuditMeta,
) (*FixtureSchedule, error) {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return nil, errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current == nil || current.Status != ScheduleStatusAccepted {
return nil, BadRequest("no accepted schedule to reschedule")
}
now := time.Now().Unix()
current.Status = ScheduleStatusRescheduled
current.RescheduleReason = &reason
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "reschedule_reason", "updated_at").
Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "UpdateByID")
}
// Create new pending proposal
schedule := &FixtureSchedule{
FixtureID: fixtureID,
ScheduledTime: &newTime,
ProposedByTeamID: proposedByTeamID,
Status: ScheduleStatusPending,
CreatedAt: time.Now().Unix(),
}
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.reschedule",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"proposed_by": proposedByTeamID,
"new_time": newTime,
"reason": reason,
"old_schedule_id": current.ID,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return schedule, nil
}
// PostponeFixtureSchedule marks the current accepted schedule as postponed.
// This is a terminal state — a new proposal can be created afterwards.
func PostponeFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID int,
reason string,
audit *AuditMeta,
) error {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current == nil || current.Status != ScheduleStatusAccepted {
return BadRequest("no accepted schedule to postpone")
}
now := time.Now().Unix()
current.Status = ScheduleStatusPostponed
current.RescheduleReason = &reason
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "reschedule_reason", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.postpone",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"reason": reason,
"old_schedule_id": current.ID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// WithdrawFixtureSchedule allows the proposer to withdraw their pending proposal.
// Only the team that proposed can withdraw it.
func WithdrawFixtureSchedule(
ctx context.Context,
tx bun.Tx,
scheduleID, withdrawByTeamID int,
audit *AuditMeta,
) error {
schedule, err := GetByID[FixtureSchedule](tx, scheduleID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetByID")
}
if schedule.Status != ScheduleStatusPending {
return BadRequest("schedule is not in pending status")
}
if schedule.ProposedByTeamID != withdrawByTeamID {
return BadRequest("only the proposing team can withdraw their proposal")
}
now := time.Now().Unix()
schedule.Status = ScheduleStatusWithdrawn
schedule.UpdatedAt = &now
err = UpdateByID(tx, schedule.ID, schedule).
Column("status", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.withdraw",
ResourceType: "fixture_schedule",
ResourceID: scheduleID,
Details: map[string]any{
"fixture_id": schedule.FixtureID,
"withdrawn_by": withdrawByTeamID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// CancelFixtureSchedule marks the current schedule as cancelled. This is a terminal state.
// Requires fixtures.manage permission (moderator-level).
func CancelFixtureSchedule(
ctx context.Context,
tx bun.Tx,
fixtureID int,
reason string,
audit *AuditMeta,
) error {
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetCurrentFixtureSchedule")
}
if current == nil {
return BadRequest("no schedule to cancel")
}
if current.Status.IsTerminal() {
return BadRequest("schedule is already in a terminal state")
}
now := time.Now().Unix()
current.Status = ScheduleStatusCancelled
current.RescheduleReason = &reason
current.UpdatedAt = &now
err = UpdateByID(tx, current.ID, current).
Column("status", "reschedule_reason", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_schedule.cancel",
ResourceType: "fixture_schedule",
ResourceID: fixtureID,
Details: map[string]any{
"fixture_id": fixtureID,
"reason": reason,
"schedule_id": current.ID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}

342
internal/db/freeagent.go Normal file
View File

@@ -0,0 +1,342 @@
package db
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// SeasonLeagueFreeAgent tracks players registered as free agents in a season_league.
type SeasonLeagueFreeAgent struct {
bun.BaseModel `bun:"table:season_league_free_agents,alias:slfa"`
SeasonID int `bun:",pk,notnull"`
LeagueID int `bun:",pk,notnull"`
PlayerID int `bun:",pk,notnull"`
RegisteredAt int64 `bun:",notnull"`
RegisteredByUserID int `bun:",notnull"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
RegisteredBy *User `bun:"rel:belongs-to,join:registered_by_user_id=id"`
}
// FixtureFreeAgent tracks which free agents are nominated for specific fixtures.
type FixtureFreeAgent struct {
bun.BaseModel `bun:"table:fixture_free_agents,alias:ffa"`
FixtureID int `bun:",pk,notnull"`
PlayerID int `bun:",pk,notnull"`
TeamID int `bun:",notnull"`
NominatedByUserID int `bun:",notnull"`
NominatedAt int64 `bun:",notnull"`
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
NominatedBy *User `bun:"rel:belongs-to,join:nominated_by_user_id=id"`
}
// RegisterFreeAgent registers a player as a free agent in a season_league.
func RegisterFreeAgent(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
audit *AuditMeta,
) error {
user := CurrentUser(ctx)
if user == nil {
return errors.New("user cannot be nil")
}
entry := &SeasonLeagueFreeAgent{
SeasonID: seasonID,
LeagueID: leagueID,
PlayerID: playerID,
RegisteredAt: time.Now().Unix(),
RegisteredByUserID: user.ID,
}
info := &AuditInfo{
Action: "free_agents.add",
ResourceType: "season_league_free_agent",
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
Details: map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
},
}
err := Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// UnregisterFreeAgent removes a player's free agent registration and all their nominations.
func UnregisterFreeAgent(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
audit *AuditMeta,
) error {
// First remove all nominations for this player
err := RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
}
// Then remove the registration
_, err = tx.NewDelete().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
info := &AuditInfo{
Action: "free_agents.remove",
ResourceType: "season_league_free_agent",
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
Details: map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
},
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
// GetFreeAgentsForSeasonLeague returns all players registered as free agents in a season_league.
func GetFreeAgentsForSeasonLeague(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*SeasonLeagueFreeAgent, error) {
entries := []*SeasonLeagueFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("slfa.season_id = ?", seasonID).
Where("slfa.league_id = ?", leagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Relation("RegisteredBy").
Order("slfa.registered_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// IsFreeAgentRegistered checks if a player is registered as a free agent in a season_league.
func IsFreeAgentRegistered(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID, playerID int,
) (bool, error) {
count, err := tx.NewSelect().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("player_id = ?", playerID).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count > 0, nil
}
// NominateFreeAgent nominates a free agent for a specific fixture on behalf of a team.
func NominateFreeAgent(
ctx context.Context,
tx bun.Tx,
fixtureID, playerID, teamID int,
audit *AuditMeta,
) error {
user := CurrentUser(ctx)
if user == nil {
return errors.New("user cannot be nil")
}
// Check if already nominated by another team
existing := new(FixtureFreeAgent)
err := tx.NewSelect().
Model(existing).
Where("ffa.fixture_id = ?", fixtureID).
Where("ffa.player_id = ?", playerID).
Scan(ctx)
if err == nil {
// Found existing nomination
if existing.TeamID != teamID {
return BadRequest("Player already nominated for this fixture by another team")
}
return BadRequest("Player already nominated for this fixture")
}
if err.Error() != "sql: no rows in result set" {
return errors.Wrap(err, "tx.NewSelect")
}
// Check max 2 free agents per team per fixture
count, err := tx.NewSelect().
Model((*FixtureFreeAgent)(nil)).
Where("fixture_id = ?", fixtureID).
Where("team_id = ?", teamID).
Count(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewSelect count")
}
if count >= 2 {
return BadRequest("Maximum of 2 free agents per team per fixture")
}
entry := &FixtureFreeAgent{
FixtureID: fixtureID,
PlayerID: playerID,
TeamID: teamID,
NominatedByUserID: user.ID,
NominatedAt: time.Now().Unix(),
}
info := &AuditInfo{
Action: "free_agents.nominate",
ResourceType: "fixture_free_agent",
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
Details: map[string]any{
"fixture_id": fixtureID,
"player_id": playerID,
"team_id": teamID,
},
}
err = Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// GetNominatedFreeAgents returns all free agents nominated for a fixture.
func GetNominatedFreeAgents(
ctx context.Context,
tx bun.Tx,
fixtureID int,
) ([]*FixtureFreeAgent, error) {
entries := []*FixtureFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("ffa.fixture_id = ?", fixtureID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Relation("Team").
Relation("NominatedBy").
Order("ffa.nominated_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// GetNominatedFreeAgentsByTeam returns free agents nominated by a specific team for a fixture.
func GetNominatedFreeAgentsByTeam(
ctx context.Context,
tx bun.Tx,
fixtureID, teamID int,
) ([]*FixtureFreeAgent, error) {
entries := []*FixtureFreeAgent{}
err := tx.NewSelect().
Model(&entries).
Where("ffa.fixture_id = ?", fixtureID).
Where("ffa.team_id = ?", teamID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Order("ffa.nominated_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return entries, nil
}
// RemoveAllFreeAgentNominationsForPlayer deletes all nominations for a player.
// Used for cascade deletion on team join and unregister.
func RemoveAllFreeAgentNominationsForPlayer(
ctx context.Context,
tx bun.Tx,
playerID int,
) error {
_, err := tx.NewDelete().
Model((*FixtureFreeAgent)(nil)).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}
// RemoveFreeAgentNomination removes a specific nomination.
func RemoveFreeAgentNomination(
ctx context.Context,
tx bun.Tx,
fixtureID, playerID int,
audit *AuditMeta,
) error {
_, err := tx.NewDelete().
Model((*FixtureFreeAgent)(nil)).
Where("fixture_id = ?", fixtureID).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
info := &AuditInfo{
Action: "free_agents.remove_nomination",
ResourceType: "fixture_free_agent",
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
Details: map[string]any{
"fixture_id": fixtureID,
"player_id": playerID,
},
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}
// RemoveFreeAgentRegistrationForPlayer removes all free agent registrations for a player.
// Used on team join.
func RemoveFreeAgentRegistrationForPlayer(
ctx context.Context,
tx bun.Tx,
playerID int,
) error {
_, err := tx.NewDelete().
Model((*SeasonLeagueFreeAgent)(nil)).
Where("player_id = ?", playerID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}

View File

@@ -24,7 +24,8 @@ func (g *fieldgetter[T]) get(ctx context.Context) (*T, error) {
Scan(ctx)
if err != nil {
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")
}
@@ -36,6 +37,10 @@ func (g *fieldgetter[T]) Get(ctx context.Context) (*T, error) {
return g.get(ctx)
}
func (g *fieldgetter[T]) String() string {
return g.q.String()
}
func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] {
g.q = g.q.Relation(name, apply...)
return g
@@ -65,5 +70,6 @@ func GetByID[T any](
tx bun.Tx,
id int,
) *fieldgetter[T] {
return GetByField[T](tx, "id", id)
prefix := extractTableAlias[T]()
return GetByField[T](tx, prefix+".id", id)
}

View File

@@ -33,6 +33,7 @@ const (
LessEqual Comparator = "<="
Greater Comparator = ">"
GreaterEqual Comparator = ">="
In Comparator = "IN"
)
type ListFilter struct {
@@ -63,6 +64,10 @@ func (f *ListFilter) GreaterEqualThan(field string, value any) {
f.filters = append(f.filters, Filter{field, value, GreaterEqual})
}
func (f *ListFilter) In(field string, values any) {
f.filters = append(f.filters, Filter{field, values, In})
}
func GetList[T any](tx bun.Tx) *listgetter[T] {
l := &listgetter[T]{
items: new([]*T),
@@ -72,6 +77,10 @@ func GetList[T any](tx bun.Tx) *listgetter[T] {
return l
}
func (l *listgetter[T]) String() string {
return l.q.String()
}
func (l *listgetter[T]) Join(join string, args ...any) *listgetter[T] {
l.q = l.q.Join(join, args...)
return l
@@ -82,6 +91,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
@@ -89,7 +103,11 @@ func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *b
func (l *listgetter[T]) Filter(filters ...Filter) *listgetter[T] {
for _, filter := range filters {
l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Comparator), filter.Value)
if filter.Comparator == In {
l.q = l.q.Where("? IN (?)", bun.Ident(filter.Field), bun.In(filter.Value))
} else {
l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Comparator), filter.Value)
}
}
return l
}
@@ -115,6 +133,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

@@ -3,7 +3,6 @@ package db
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/pkg/errors"
@@ -11,13 +10,13 @@ import (
)
type inserter[T any] struct {
tx bun.Tx
q *bun.InsertQuery
model *T
models []*T
isBulk bool
auditCallback AuditCallback
auditRequest *http.Request
tx bun.Tx
q *bun.InsertQuery
model *T
models []*T
isBulk bool
audit *AuditMeta
auditInfo *AuditInfo
}
// Insert creates an inserter for a single model
@@ -76,11 +75,10 @@ func (i *inserter[T]) Returning(columns ...string) *inserter[T] {
}
// WithAudit enables audit logging for this insert operation
// The callback will be invoked after successful insert with auto-generated audit info
// If the callback returns an error, the transaction will be rolled back
func (i *inserter[T]) WithAudit(r *http.Request, callback AuditCallback) *inserter[T] {
i.auditRequest = r
i.auditCallback = callback
// If the provided *AuditInfo is nil, will use reflection to automatically work out the details
func (i *inserter[T]) WithAudit(meta *AuditMeta, info *AuditInfo) *inserter[T] {
i.audit = meta
i.auditInfo = info
return i
}
@@ -94,35 +92,30 @@ func (i *inserter[T]) Exec(ctx context.Context) error {
}
// Handle audit logging if enabled
if i.auditCallback != nil && i.auditRequest != nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "create")
var info *AuditInfo
if i.isBulk {
// For bulk inserts, log once with count in details
info = &AuditInfo{
if i.audit != nil {
if i.auditInfo == nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "create")
i.auditInfo = &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: nil,
Details: map[string]any{
"count": len(i.models),
},
}
} else {
// For single insert, log with resource ID
info = &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: extractPrimaryKey(i.model),
Details: nil,
}
if i.isBulk {
i.auditInfo.Details = map[string]any{
"count": len(i.models),
}
} else {
i.auditInfo.ResourceID = extractPrimaryKey(i.model)
i.auditInfo.Details = i.model
}
}
// Call audit callback - if it fails, return error to trigger rollback
if err := i.auditCallback(ctx, i.tx, info, i.auditRequest); err != nil {
return errors.Wrap(err, "audit.callback")
err = LogSuccess(ctx, i.tx, i.audit, i.auditInfo)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
}

View File

@@ -10,20 +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"`
}
type SeasonLeague struct {
SeasonID int `bun:",pk"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
LeagueID int `bun:",pk"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
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) {
@@ -37,41 +30,16 @@ func GetLeague(ctx context.Context, tx bun.Tx, shortname string) (*League, error
return GetByField[League](tx, "short_name", shortname).Relation("Seasons").Get(ctx)
}
// GetSeasonLeague retrieves a specific season-league combination with teams
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
if seasonShortName == "" {
return nil, nil, nil, errors.New("season short_name cannot be empty")
func NewLeague(ctx context.Context, tx bun.Tx, name, shortname, description string, audit *AuditMeta) (*League, error) {
league := &League{
Name: name,
ShortName: shortname,
Description: description,
}
if leagueShortName == "" {
return nil, nil, nil, errors.New("league short_name cannot be empty")
}
// Get the season
season, err := GetSeason(ctx, tx, seasonShortName)
err := Insert(tx, league).
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason")
return nil, errors.Wrap(err, "db.Insert")
}
// Get the league
league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetLeague")
}
if season == nil || league == nil || !season.HasLeague(league.ID) {
return nil, nil, nil, nil
}
// Get all teams participating in this season+league
var teams []*Team
err = tx.NewSelect().
Model(&teams).
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
Order("t.name ASC").
Scan(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
}
return season, league, teams, nil
return league, nil
}

View File

@@ -1,30 +1,29 @@
package main
// Package migrate provides functions for managing database migrations
package migrate
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"text/tabwriter"
"time"
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
"git.haelnorr.com/h/oslstats/internal/backup"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/db/migrations"
"github.com/pkg/errors"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// runMigrations executes database migrations
func runMigrations(ctx context.Context, cfg *config.Config, command string, countStr string) error {
conn, close := setupBun(cfg)
defer func() { _ = close() }()
// RunMigrations executes database migrations
func RunMigrations(ctx context.Context, cfg *config.Config, command string, countStr string) error {
conn := db.NewDB(cfg.DB)
defer func() { _ = conn.Close() }()
migrator := migrate.NewMigrator(conn, migrations.Migrations)
migrator := migrate.NewMigrator(conn.DB, migrations.Migrations)
// Initialize migration tables
if err := migrator.Init(ctx); err != nil {
@@ -47,28 +46,20 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string, coun
return migrateRollback(ctx, migrator, conn, cfg, countStr)
case "status":
return migrateStatus(ctx, migrator)
case "dry-run":
return migrateDryRun(ctx, migrator)
default:
return fmt.Errorf("unknown migration command: %s", command)
}
}
// migrateUp runs pending migrations
func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config, countStr string) error {
func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *db.DB, cfg *config.Config, countStr string) error {
// Parse count parameter
count, all, err := parseMigrationCount(countStr)
if err != nil {
return errors.Wrap(err, "parse migration count")
}
fmt.Println("[INFO] Step 1/5: Validating migrations...")
if err := validateMigrations(ctx); err != nil {
return err
}
fmt.Println("[INFO] Migration validation passed ✓")
fmt.Println("[INFO] Step 2/5: Checking for pending migrations...")
fmt.Println("[INFO] Step 1/4: Checking for pending migrations...")
// Check for pending migrations using MigrationsWithStatus (read-only)
ms, err := migrator.MigrationsWithStatus(ctx)
if err != nil {
@@ -100,22 +91,22 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
// Create backup unless --no-backup flag is set
if !cfg.Flags.MigrateNoBackup {
fmt.Println("[INFO] Step 3/5: Creating backup...")
_, err := backup.CreateBackup(ctx, cfg, "migration")
fmt.Println("[INFO] Step 2/4: Creating backup...")
_, err := db.CreateBackup(ctx, cfg.DB, "migration")
if err != nil {
return errors.Wrap(err, "create backup")
}
// Clean old backups
if err := backup.CleanOldBackups(cfg, cfg.DB.BackupRetention); err != nil {
if err := db.CleanOldBackups(cfg.DB, cfg.DB.BackupRetention); err != nil {
fmt.Printf("[WARN] Failed to clean old backups: %v\n", err)
}
} else {
fmt.Println("[INFO] Step 3/5: Skipping backup (--no-backup flag set)")
fmt.Println("[INFO] Step 2/4: Skipping backup (--no-backup flag set)")
}
// Acquire migration lock
fmt.Println("[INFO] Step 4/5: Acquiring migration lock...")
fmt.Println("[INFO] Step 3/4: Acquiring migration lock...")
if err := acquireMigrationLock(ctx, conn); err != nil {
return errors.Wrap(err, "acquire migration lock")
}
@@ -123,7 +114,7 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
fmt.Println("[INFO] Migration lock acquired")
// Run migrations
fmt.Println("[INFO] Step 5/5: Applying migrations...")
fmt.Println("[INFO] Step 4/4: Applying migrations...")
group, err := executeUpMigrations(ctx, migrator, toApply)
if err != nil {
return errors.Wrap(err, "execute migrations")
@@ -143,7 +134,7 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
}
// migrateRollback rolls back migrations
func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config, countStr string) error {
func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *db.DB, cfg *config.Config, countStr string) error {
// Parse count parameter
count, all, err := parseMigrationCount(countStr)
if err != nil {
@@ -182,13 +173,13 @@ func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.
// Create backup unless --no-backup flag is set
if !cfg.Flags.MigrateNoBackup {
fmt.Println("[INFO] Creating backup before rollback...")
_, err := backup.CreateBackup(ctx, cfg, "rollback")
_, err := db.CreateBackup(ctx, cfg.DB, "rollback")
if err != nil {
return errors.Wrap(err, "create backup")
}
// Clean old backups
if err := backup.CleanOldBackups(cfg, cfg.DB.BackupRetention); err != nil {
if err := db.CleanOldBackups(cfg.DB, cfg.DB.BackupRetention); err != nil {
fmt.Printf("[WARN] Failed to clean old backups: %v\n", err)
}
} else {
@@ -229,26 +220,22 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error {
fmt.Println("║ DATABASE MIGRATION STATUS ║")
fmt.Println("╚══════════════════════════════════════════════════════════╝")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
_, _ = fmt.Fprintln(w, "STATUS\tMIGRATION\tGROUP\tMIGRATED AT")
_, _ = fmt.Fprintln(w, "------\t---------\t-----\t-----------")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
_, _ = fmt.Fprintln(w, "STATUS\tMIGRATION\tGROUP\tCOMMENT")
_, _ = fmt.Fprintln(w, "----------\t---------------\t-----\t---------------------------")
appliedCount := 0
for _, m := range ms {
status := "⏳ Pending"
migratedAt := "-"
group := "-"
if m.GroupID > 0 {
status = "✅ Applied"
appliedCount++
group = fmt.Sprint(m.GroupID)
if !m.MigratedAt.IsZero() {
migratedAt = m.MigratedAt.Format("2006-01-02 15:04:05")
}
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, m.Name, group, migratedAt)
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, m.Name, group, m.Comment)
}
_ = w.Flush()
@@ -259,44 +246,8 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error {
return nil
}
// migrateDryRun shows what migrations would run without applying them
func migrateDryRun(ctx context.Context, migrator *migrate.Migrator) error {
group, err := migrator.Migrate(ctx, migrate.WithNopMigration())
if err != nil {
return errors.Wrap(err, "dry-run")
}
if group.IsZero() {
fmt.Println("[INFO] No pending migrations")
return nil
}
fmt.Println("[INFO] Pending migrations (dry-run):")
for _, migration := range group.Migrations {
fmt.Printf(" 📋 %s\n", migration.Name)
}
fmt.Printf("[INFO] Would migrate to group %d\n", group.ID)
return nil
}
// validateMigrations ensures migrations compile before running
func validateMigrations(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "go", "build",
"-o", "/dev/null", "./cmd/oslstats/migrations")
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println("[ERROR] Migration validation failed!")
fmt.Println(string(output))
return errors.Wrap(err, "migration build failed")
}
return nil
}
// acquireMigrationLock prevents concurrent migrations using PostgreSQL advisory lock
func acquireMigrationLock(ctx context.Context, conn *bun.DB) error {
func acquireMigrationLock(ctx context.Context, conn *db.DB) error {
const lockID = 1234567890 // Arbitrary unique ID for migration lock
const timeoutSeconds = 300 // 5 minutes
@@ -322,7 +273,7 @@ func acquireMigrationLock(ctx context.Context, conn *bun.DB) error {
}
// releaseMigrationLock releases the migration lock
func releaseMigrationLock(ctx context.Context, conn *bun.DB) {
func releaseMigrationLock(ctx context.Context, conn *db.DB) {
const lockID = 1234567890
_, err := conn.NewRaw("SELECT pg_advisory_unlock(?)", lockID).Exec(ctx)
@@ -333,8 +284,8 @@ func releaseMigrationLock(ctx context.Context, conn *bun.DB) {
}
}
// createMigration generates a new migration file
func createMigration(name string) error {
// CreateMigration generates a new migration file
func CreateMigration(name string) error {
if name == "" {
return errors.New("migration name cannot be empty")
}
@@ -344,7 +295,7 @@ func createMigration(name string) error {
// Generate timestamp
timestamp := time.Now().Format("20060102150405")
filename := fmt.Sprintf("cmd/oslstats/migrations/%s_%s.go", timestamp, name)
filename := fmt.Sprintf("internal/db/migrations/%s_%s.go", timestamp, name)
// Template
template := `package migrations
@@ -357,12 +308,12 @@ import (
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
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
},
@@ -378,7 +329,7 @@ func init() {
fmt.Printf("✅ Created migration: %s\n", filename)
fmt.Println("📝 Next steps:")
fmt.Println(" 1. Edit the file and implement the UP and DOWN functions")
fmt.Println(" 2. Run: make migrate")
fmt.Println(" 2. Run: just migrate up")
return nil
}
@@ -506,8 +457,8 @@ func executeDownMigrations(ctx context.Context, migrator *migrate.Migrator, migr
return rolledBack, nil
}
// resetDatabase drops and recreates all tables (destructive)
func resetDatabase(ctx context.Context, cfg *config.Config) error {
// ResetDatabase drops and recreates all tables (destructive)
func ResetDatabase(ctx context.Context, cfg *config.Config) error {
fmt.Println("⚠️ WARNING - This will DELETE ALL DATA in the database!")
fmt.Print("Type 'yes' to continue: ")
@@ -522,16 +473,25 @@ func resetDatabase(ctx context.Context, cfg *config.Config) error {
fmt.Println("❌ Reset cancelled")
return nil
}
conn, close := setupBun(cfg)
defer func() { _ = close() }()
conn := db.NewDB(cfg.DB)
defer func() { _ = conn.Close() }()
models := registerDBModels(conn)
conn.RegisterModels()
for _, model := range models {
if err := conn.ResetModel(ctx, model); err != nil {
return errors.Wrap(err, "reset model")
}
err = RunMigrations(ctx, cfg, "rollback", "all")
if err != nil {
return errors.Wrap(err, "RunMigrations: rollback")
}
err = RunMigrations(ctx, cfg, "up", "all")
if err != nil {
return errors.Wrap(err, "RunMigrations: up")
}
// for _, model := range models {
// if err := conn.ResetModel(ctx, model); err != nil {
// return errors.Wrap(err, "reset model")
// }
// }
fmt.Println("✅ Database reset complete")
return nil

View File

@@ -10,9 +10,9 @@ import (
func init() {
Migrations.MustRegister(
// UP: Create initial tables (users, discord_tokens)
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Create users table
_, err := dbConn.NewCreateTable().
_, err := conn.NewCreateTable().
Model((*db.User)(nil)).
Exec(ctx)
if err != nil {
@@ -20,15 +20,15 @@ func init() {
}
// Create discord_tokens table
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.DiscordToken)(nil)).
Exec(ctx)
return err
},
// DOWN: Drop tables in reverse order
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Drop discord_tokens first (has foreign key to users)
_, err := dbConn.NewDropTable().
_, err := conn.NewDropTable().
Model((*db.DiscordToken)(nil)).
IfExists().
Exec(ctx)
@@ -37,7 +37,7 @@ func init() {
}
// Drop users table
_, err = dbConn.NewDropTable().
_, err = conn.NewDropTable().
Model((*db.User)(nil)).
IfExists().
Exec(ctx)

View File

@@ -10,8 +10,8 @@ import (
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, dbConn *bun.DB) error {
_, err := dbConn.NewCreateTable().
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewCreateTable().
Model((*db.Season)(nil)).
Exec(ctx)
if err != nil {
@@ -20,8 +20,8 @@ func init() {
return nil
},
// DOWN migration
func(ctx context.Context, dbConn *bun.DB) error {
_, err := dbConn.NewDropTable().
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropTable().
Model((*db.Season)(nil)).
IfExists().
Exec(ctx)

View File

@@ -12,10 +12,10 @@ import (
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, dbConn *bun.DB) error {
dbConn.RegisterModel((*db.RolePermission)(nil), (*db.UserRole)(nil))
func(ctx context.Context, conn *bun.DB) error {
conn.RegisterModel((*db.RolePermission)(nil), (*db.UserRole)(nil))
// Create permissions table
_, err := dbConn.NewCreateTable().
_, err := conn.NewCreateTable().
Model((*db.Role)(nil)).
Exec(ctx)
if err != nil {
@@ -23,7 +23,7 @@ func init() {
}
// Create permissions table
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.Permission)(nil)).
Exec(ctx)
if err != nil {
@@ -31,7 +31,7 @@ func init() {
}
// Create indexes for permissions
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.Permission)(nil)).
Index("idx_permissions_resource").
Column("resource").
@@ -40,7 +40,7 @@ func init() {
return err
}
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.Permission)(nil)).
Index("idx_permissions_action").
Column("action").
@@ -49,21 +49,21 @@ func init() {
return err
}
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.RolePermission)(nil)).
Exec(ctx)
if err != nil {
return err
}
_, err = dbConn.ExecContext(ctx, `
_, err = conn.ExecContext(ctx, `
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id)
`)
if err != nil {
return err
}
_, err = dbConn.ExecContext(ctx, `
_, err = conn.ExecContext(ctx, `
CREATE INDEX idx_role_permissions_permission ON role_permissions(permission_id)
`)
if err != nil {
@@ -71,7 +71,7 @@ func init() {
}
// Create user_roles table
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.UserRole)(nil)).
Exec(ctx)
if err != nil {
@@ -79,7 +79,7 @@ func init() {
}
// Create indexes for user_roles
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.UserRole)(nil)).
Index("idx_user_roles_user").
Column("user_id").
@@ -88,7 +88,7 @@ func init() {
return err
}
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.UserRole)(nil)).
Index("idx_user_roles_role").
Column("role_id").
@@ -98,7 +98,7 @@ func init() {
}
// Create audit_log table
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.AuditLog)(nil)).
Exec(ctx)
if err != nil {
@@ -106,7 +106,7 @@ func init() {
}
// Create indexes for audit_log
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.AuditLog)(nil)).
Index("idx_audit_log_user").
Column("user_id").
@@ -115,7 +115,7 @@ func init() {
return err
}
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.AuditLog)(nil)).
Index("idx_audit_log_action").
Column("action").
@@ -124,7 +124,7 @@ func init() {
return err
}
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.AuditLog)(nil)).
Index("idx_audit_log_resource").
Column("resource_type", "resource_id").
@@ -133,7 +133,7 @@ func init() {
return err
}
_, err = dbConn.NewCreateIndex().
_, err = conn.NewCreateIndex().
Model((*db.AuditLog)(nil)).
Index("idx_audit_log_created").
Column("created_at").
@@ -142,7 +142,7 @@ func init() {
return err
}
err = seedSystemRBAC(ctx, dbConn)
err = seedSystemRBAC(ctx, conn)
if err != nil {
return err
}
@@ -173,7 +173,7 @@ func init() {
)
}
func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
func seedSystemRBAC(ctx context.Context, conn *bun.DB) error {
// Seed system roles
now := time.Now().Unix()
@@ -185,7 +185,7 @@ func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
CreatedAt: now,
}
_, err := dbConn.NewInsert().
_, err := conn.NewInsert().
Model(adminRole).
Returning("id").
Exec(ctx)
@@ -201,7 +201,7 @@ func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
CreatedAt: now,
}
_, err = dbConn.NewInsert().
_, err = conn.NewInsert().
Model(userRole).
Exec(ctx)
if err != nil {
@@ -219,7 +219,7 @@ func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
}
_, err = dbConn.NewInsert().
_, err = conn.NewInsert().
Model(&permissionsData).
Exec(ctx)
if err != nil {
@@ -229,7 +229,7 @@ func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
// Grant wildcard permission to admin role using Bun
// First, get the IDs
var wildcardPerm db.Permission
err = dbConn.NewSelect().
err = conn.NewSelect().
Model(&wildcardPerm).
Where("name = ?", "*").
Scan(ctx)
@@ -242,7 +242,7 @@ func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
RoleID: adminRole.ID,
PermissionID: wildcardPerm.ID,
}
_, err = dbConn.NewInsert().
_, err = conn.NewInsert().
Model(adminRolePerms).
On("CONFLICT (role_id, permission_id) DO NOTHING").
Exec(ctx)

View File

@@ -11,9 +11,9 @@ import (
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Add slap_version column to seasons table
_, err := dbConn.NewAddColumn().
_, err := conn.NewAddColumn().
Model((*db.Season)(nil)).
ColumnExpr("slap_version VARCHAR NOT NULL DEFAULT 'rebound'").
IfNotExists().
@@ -23,7 +23,7 @@ func init() {
}
// Create leagues table
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.League)(nil)).
Exec(ctx)
if err != nil {
@@ -31,15 +31,15 @@ func init() {
}
// Create season_leagues join table
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.SeasonLeague)(nil)).
Exec(ctx)
return err
},
// DOWN migration
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Drop season_leagues join table first
_, err := dbConn.NewDropTable().
_, err := conn.NewDropTable().
Model((*db.SeasonLeague)(nil)).
IfExists().
Exec(ctx)
@@ -48,20 +48,14 @@ func init() {
}
// Drop leagues table
_, err = dbConn.NewDropTable().
_, err = conn.NewDropTable().
Model((*db.League)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
// Remove slap_version column from seasons table
_, err = dbConn.NewDropColumn().
Model((*db.Season)(nil)).
ColumnExpr("slap_version").
Exec(ctx)
return err
return nil
},
)
}

View File

@@ -10,15 +10,15 @@ import (
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, dbConn *bun.DB) error {
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
_, err := dbConn.NewCreateTable().
_, err := conn.NewCreateTable().
Model((*db.Team)(nil)).
Exec(ctx)
if err != nil {
return err
}
_, err = dbConn.NewCreateTable().
_, err = conn.NewCreateTable().
Model((*db.TeamParticipation)(nil)).
Exec(ctx)
if err != nil {
@@ -27,16 +27,16 @@ func init() {
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
_, err := dbConn.NewDropTable().
_, err := conn.NewDropTable().
Model((*db.TeamParticipation)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
_, err = dbConn.NewDropTable().
_, err = conn.NewDropTable().
Model((*db.Team)(nil)).
IfExists().
Exec(ctx)

View File

@@ -0,0 +1,44 @@
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
now := time.Now().Unix()
permissionsData := []*db.Permission{
{Name: "seasons.add_league", DisplayName: "Add Leagues to Season", Description: "Assign an existing league to Seasons", Resource: "seasons", Action: "add_league", IsSystem: true, CreatedAt: now},
{Name: "seasons.remove_league", DisplayName: "Remove Leagues from a Season", Description: "Remove an assigned league league from Seasons", Resource: "seasons", Action: "remove_league", IsSystem: true, CreatedAt: now},
{Name: "leagues.create", DisplayName: "Create Leagues", Description: "Create new leagues", Resource: "leagues", Action: "create", IsSystem: true, CreatedAt: now},
{Name: "leagues.update", DisplayName: "Update Leagues", Description: "Update existing leagues", Resource: "leagues", Action: "update", IsSystem: true, CreatedAt: now},
{Name: "leagues.delete", DisplayName: "Delete Leagues", Description: "Delete leagues", Resource: "leagues", Action: "delete", IsSystem: true, CreatedAt: now},
{Name: "teams.create", DisplayName: "Create Teams", Description: "Create new teams", Resource: "teams", Action: "create", IsSystem: true, CreatedAt: now},
{Name: "teams.update", DisplayName: "Update Teams", Description: "Update existing teams", Resource: "teams", Action: "update", IsSystem: true, CreatedAt: now},
{Name: "teams.delete", DisplayName: "Delete Teams", Description: "Delete teams", Resource: "teams", Action: "delete", IsSystem: true, CreatedAt: now},
{Name: "teams.add_to_league", DisplayName: "Add Teams to League", Description: "Add an existing team to a league/season", Resource: "teams", Action: "add_to_league", 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
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

@@ -0,0 +1,37 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
_, err := conn.NewCreateTable().
Model((*db.Player)(nil)).
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
_, err := conn.NewDropTable().
Model((*db.Player)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
)
}

View File

@@ -0,0 +1,63 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
_, err := conn.NewAddColumn().
Model((*db.Season)(nil)).
IfNotExists().
ColumnExpr("type VARCHAR NOT NULL").
Exec(ctx)
if err != nil {
return err
}
leagues := []db.League{
{
Name: "Pro League",
ShortName: "Pro",
Description: "For the most experienced Slapshotters in OSL",
},
{
Name: "Intermediate League",
ShortName: "IM",
Description: "For returning players who've been practicing in RPUGs and PUBs",
},
{
Name: "Open League",
ShortName: "Open",
Description: "For new players just getting started with Slapshot",
},
{
Name: "Draft League",
ShortName: "Draft",
Description: "A league where teams are selected by a draft system",
},
}
for _, league := range leagues {
_, err = conn.NewInsert().
Model(&league).
On("CONFLICT DO NOTHING").
Exec(ctx)
if err != nil {
return err
}
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
return nil
},
)
}

View File

@@ -0,0 +1,31 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
_, err := conn.NewAddColumn().
Model((*db.Player)(nil)).
IfNotExists().
ColumnExpr("name VARCHAR NOT NULL").
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
return nil
},
)
}

View File

@@ -0,0 +1,30 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here
_, err := conn.NewCreateTable().
IfNotExists().
Model((*db.TeamRoster)(nil)).
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here
return nil
},
)
}

View File

@@ -0,0 +1,58 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewCreateTable().
Model((*db.FixtureSchedule)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`).
ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_id for faster lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureSchedule)(nil)).
Index("idx_fixture_schedules_fixture_id").
Column("fixture_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Create index on status for filtering
_, err = conn.NewCreateIndex().
Model((*db.FixtureSchedule)(nil)).
Index("idx_fixture_schedules_status").
Column("status").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
_, err := conn.NewDropTable().
Model((*db.FixtureSchedule)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

View File

@@ -0,0 +1,91 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Create fixture_results table
_, err := conn.NewCreateTable().
Model((*db.FixtureResult)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("uploaded_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create fixture_result_player_stats table
_, err = conn.NewCreateTable().
Model((*db.FixtureResultPlayerStats)(nil)).
IfNotExists().
ForeignKey(`("fixture_result_id") REFERENCES "fixture_results" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE SET NULL`).
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE SET NULL`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_result_id for faster lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureResultPlayerStats)(nil)).
Index("idx_frps_fixture_result_id").
Column("fixture_result_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Create index on player_id for stats queries
_, err = conn.NewCreateIndex().
Model((*db.FixtureResultPlayerStats)(nil)).
Index("idx_frps_player_id").
Column("player_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Create composite index for period+team filtering
_, err = conn.NewCreateIndex().
Model((*db.FixtureResultPlayerStats)(nil)).
Index("idx_frps_result_period_team").
Column("fixture_result_id", "period_num", "team").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Drop fixture_result_player_stats first (has FK to fixture_results)
_, err := conn.NewDropTable().
Model((*db.FixtureResultPlayerStats)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
// Drop fixture_results
_, err = conn.NewDropTable().
Model((*db.FixtureResult)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

View File

@@ -0,0 +1,91 @@
package migrations
import (
"context"
"git.haelnorr.com/h/oslstats/internal/db"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
// UP migration
func(ctx context.Context, conn *bun.DB) error {
// Create season_league_free_agents table
_, err := conn.NewCreateTable().
Model((*db.SeasonLeagueFreeAgent)(nil)).
IfNotExists().
ForeignKey(`("season_id") REFERENCES "seasons" ("id") ON DELETE CASCADE`).
ForeignKey(`("league_id") REFERENCES "leagues" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
ForeignKey(`("registered_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create fixture_free_agents table
_, err = conn.NewCreateTable().
Model((*db.FixtureFreeAgent)(nil)).
IfNotExists().
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE CASCADE`).
ForeignKey(`("nominated_by_user_id") REFERENCES "users" ("id")`).
Exec(ctx)
if err != nil {
return err
}
// Create index on fixture_free_agents for team lookups
_, err = conn.NewCreateIndex().
Model((*db.FixtureFreeAgent)(nil)).
Index("idx_ffa_fixture_team").
Column("fixture_id", "team_id").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
// Add is_free_agent column to fixture_result_player_stats
_, err = conn.NewAddColumn().
Model((*db.FixtureResultPlayerStats)(nil)).
ColumnExpr("is_free_agent BOOLEAN NOT NULL DEFAULT false").
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
return nil
},
// DOWN migration
func(ctx context.Context, conn *bun.DB) error {
// Drop is_free_agent column from fixture_result_player_stats
_, err := conn.NewDropColumn().
Model((*db.FixtureResultPlayerStats)(nil)).
ColumnExpr("is_free_agent").
Exec(ctx)
if err != nil {
return err
}
// Drop fixture_free_agents table
_, err = conn.NewDropTable().
Model((*db.FixtureFreeAgent)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return err
}
// Drop season_league_free_agents table
_, err = conn.NewDropTable().
Model((*db.SeasonLeagueFreeAgent)(nil)).
IfExists().
Exec(ctx)
return err
},
)
}

View File

@@ -1,8 +1,11 @@
package db
import (
"net/http"
"strings"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/uptrace/bun"
)
@@ -19,6 +22,41 @@ type OrderOpts struct {
Label string
}
func GetPageOpts(s *hws.Server, w http.ResponseWriter, r *http.Request) (*PageOpts, bool) {
var getter validation.Getter
switch r.Method {
case "GET":
getter = validation.NewQueryGetter(r)
case "POST":
var ok bool
getter, ok = validation.ParseFormOrError(s, w, r)
if !ok {
return nil, false
}
default:
return nil, false
}
return getPageOpts(s, w, r, getter), true
}
func getPageOpts(s *hws.Server, w http.ResponseWriter, r *http.Request, g validation.Getter) *PageOpts {
page := g.Int("page").Optional().Min(1).Value
perPage := g.Int("per_page").Optional().Min(1).Max(100).Value
order := g.String("order").TrimSpace().ToUpper().Optional().AllowedValues([]string{"ASC", "DESC"}).Value
orderBy := g.String("order_by").TrimSpace().Optional().ToLower().Value
valid := g.ValidateAndError(s, w, r)
if !valid {
return nil
}
pageOpts := &PageOpts{
Page: page,
PerPage: perPage,
Order: bun.Order(order),
OrderBy: orderBy,
}
return pageOpts
}
func setPageOpts(q *bun.SelectQuery, p, d *PageOpts, totalitems int) (*bun.SelectQuery, *PageOpts) {
if p == nil {
p = new(PageOpts)

View File

@@ -28,7 +28,7 @@ func (p Permission) isSystem() bool {
}
// 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) {
if name == "" {
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
// 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) {
if id <= 0 {
return nil, errors.New("id must be positive")
@@ -92,5 +92,5 @@ func DeletePermission(ctx context.Context, tx bun.Tx, id int) error {
if id <= 0 {
return errors.New("id must be positive")
}
return DeleteWithProtection[Permission](ctx, tx, id)
return DeleteWithProtection[Permission](ctx, tx, id, nil)
}

123
internal/db/player.go Normal file
View File

@@ -0,0 +1,123 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Player struct {
bun.BaseModel `bun:"table:players,alias:p"`
ID int `bun:"id,pk,autoincrement" json:"id"`
SlapID *uint32 `bun:"slap_id,unique" json:"slap_id"`
DiscordID string `bun:"discord_id,unique,notnull" json:"discord_id"`
UserID *int `bun:"user_id,unique" json:"user_id"`
Name string `bun:"name,notnull" json:"name"`
User *User `bun:"rel:belongs-to,join:user_id=id" json:"-"`
}
func (p *Player) DisplayName() string {
if p.User != nil {
return p.User.Username
}
return p.Name
}
// NewPlayer creates a new player in the database. If there is an existing user with the same
// discordID, it will automatically link that user to the player
func NewPlayer(ctx context.Context, tx bun.Tx, name, discordID string, audit *AuditMeta) (*Player, error) {
player := &Player{DiscordID: discordID, Name: name}
user, err := GetUserByDiscordID(ctx, tx, discordID)
if err != nil && !IsBadRequest(err) {
return nil, errors.Wrap(err, "GetUserByDiscordID")
}
if user != nil {
player.UserID = &user.ID
player.Name = user.Username
}
err = Insert(tx, player).
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return player, nil
}
func NewPlayerFromLog(ctx context.Context, tx bun.Tx, name string, slapID uint32, audit *AuditMeta) (*Player, error) {
player := &Player{Name: name, SlapID: &slapID}
err := Insert(tx, player).
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert")
}
return player, nil
}
// ConnectPlayer links the user to an existing player, or creates a new player to link if not found
// Populates User.Player on success
func (u *User) ConnectPlayer(ctx context.Context, tx bun.Tx, audit *AuditMeta) error {
player, err := GetByField[Player](tx, "p.discord_id", u.DiscordID).
Relation("User").Get(ctx)
if err != nil {
if !IsBadRequest(err) {
// Unexpected error occured
return errors.Wrap(err, "GetByField")
}
// Player doesn't exist, create a new one
player, err = NewPlayer(ctx, tx, u.Username, u.DiscordID, audit)
if err != nil {
return errors.Wrap(err, "NewPlayer")
}
// New player should automatically get linked to the user
u.Player = player
return nil
}
// Player was found
if player.UserID != nil {
if player.UserID == &u.ID {
return nil
}
return errors.New("player with that discord_id already linked to a user")
}
player.UserID = &u.ID
err = UpdateByID(tx, player.ID, player).Column("user_id").Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
u.Player = player
return nil
}
func GetPlayer(ctx context.Context, tx bun.Tx, playerID int) (*Player, error) {
return GetByID[Player](tx, playerID).Relation("User").Get(ctx)
}
func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID uint32, audit *AuditMeta) error {
player, err := GetPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "GetPlayer")
}
player.SlapID = &slapID
err = UpdateByID(tx, player.ID, player).Column("slap_id").
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
func GetPlayersNotOnTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Player, error) {
players, err := GetList[Player](tx).Relation("User").
Join("LEFT JOIN team_rosters tr ON tr.player_id = p.id").
Where("NOT (tr.season_id = ? and tr.league_id = ?) OR (tr.season_id IS NULL and tr.league_id IS NULL)",
seasonID, leagueID).
Order("p.name ASC").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
return players, nil
}

View File

@@ -25,34 +25,22 @@ type Role struct {
Permissions []Permission `bun:"m2m:role_permissions,join:Role=Permission"`
}
type RolePermission struct {
RoleID int `bun:",pk"`
Role *Role `bun:"rel:belongs-to,join:role_id=id"`
PermissionID int `bun:",pk"`
Permission *Permission `bun:"rel:belongs-to,join:permission_id=id"`
}
func (r Role) isSystem() bool {
return r.IsSystem
}
// 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) {
if name == "" {
return nil, errors.New("name cannot be empty")
}
return GetByField[Role](tx, "name", name).Get(ctx)
return GetByField[Role](tx, "name", name).Relation("Permissions").Get(ctx)
}
// 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) {
return GetByID[Role](tx, id).Get(ctx)
}
// GetRoleWithPermissions loads a role and all its permissions
func GetRoleWithPermissions(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
return GetByID[Role](tx, id).Relation("Permissions").Get(ctx)
}
@@ -61,8 +49,19 @@ func ListAllRoles(ctx context.Context, tx bun.Tx) ([]*Role, error) {
return GetList[Role](tx).GetAll(ctx)
}
// GetRoles returns a paginated list of roles
func GetRoles(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Role], error) {
defaults := &PageOpts{
Page: 1,
PerPage: 25,
Order: bun.OrderAsc,
OrderBy: "display_name",
}
return GetList[Role](tx).GetPaged(ctx, pageOpts, defaults)
}
// CreateRole creates a new role
func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
func CreateRole(ctx context.Context, tx bun.Tx, role *Role, audit *AuditMeta) error {
if role == nil {
return errors.New("role cannot be nil")
}
@@ -70,6 +69,7 @@ func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
err := Insert(tx, role).
Returning("id").
WithAudit(audit, nil).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "db.Insert")
@@ -79,7 +79,7 @@ func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
}
// UpdateRole updates an existing role
func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
func UpdateRole(ctx context.Context, tx bun.Tx, role *Role, audit *AuditMeta) error {
if role == nil {
return errors.New("role cannot be nil")
}
@@ -89,6 +89,7 @@ func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
err := Update(tx, role).
WherePK().
WithAudit(audit, nil).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "db.Update")
@@ -98,51 +99,39 @@ func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
}
// DeleteRole deletes a role (checks IsSystem protection)
func DeleteRole(ctx context.Context, tx bun.Tx, id int) error {
// Also cleans up join table entries in role_permissions and user_roles
func DeleteRole(ctx context.Context, tx bun.Tx, id int, audit *AuditMeta) error {
if id <= 0 {
return errors.New("id must be positive")
}
return DeleteWithProtection[Role](ctx, tx, id)
}
// AddPermissionToRole grants a permission to a role
func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error {
if roleID <= 0 {
return errors.New("roleID must be positive")
// First check if role exists and is not system
role, err := GetRoleByID(ctx, tx, id)
if err != nil {
return errors.Wrap(err, "GetRoleByID")
}
if permissionID <= 0 {
return errors.New("permissionID must be positive")
if role.IsSystem {
return errors.New("cannot delete system roles")
}
rolePerm := &RolePermission{
RoleID: roleID,
PermissionID: permissionID,
}
err := Insert(tx, rolePerm).
ConflictNothing("role_id", "permission_id").
// Delete role_permissions entries
_, err = tx.NewDelete().
Model((*RolePermission)(nil)).
Where("role_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "db.Insert")
return errors.Wrap(err, "delete role_permissions")
}
return nil
}
// RemovePermissionFromRole revokes a permission from a role
func RemovePermissionFromRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error {
if roleID <= 0 {
return errors.New("roleID must be positive")
}
if permissionID <= 0 {
return errors.New("permissionID must be positive")
}
err := DeleteItem[RolePermission](tx).
Where("role_id = ?", roleID).
Where("permission_id = ?", permissionID).
Delete(ctx)
// Delete user_roles entries
_, err = tx.NewDelete().
Model((*UserRole)(nil)).
Where("role_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "DeleteItem")
return errors.Wrap(err, "delete user_roles")
}
return nil
// Finally delete the role
return DeleteWithProtection[Role](ctx, tx, id, audit)
}

View File

@@ -0,0 +1,99 @@
package db
import (
"context"
"slices"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type RolePermission struct {
RoleID int `bun:",pk"`
Role *Role `bun:"rel:belongs-to,join:role_id=id"`
PermissionID int `bun:",pk"`
Permission *Permission `bun:"rel:belongs-to,join:permission_id=id"`
}
func (r *Role) UpdatePermissions(ctx context.Context, tx bun.Tx, newPermissionsIDs []int, audit *AuditMeta) error {
addPerms, removePerms, err := detectChangedPermissions(ctx, tx, r, newPermissionsIDs)
if err != nil {
return errors.Wrap(err, "detectChangedPermissions")
}
addedPerms := []string{}
removedPerms := []string{}
for _, perm := range addPerms {
rolePerm := &RolePermission{
RoleID: r.ID,
PermissionID: perm.ID,
}
err := Insert(tx, rolePerm).
ConflictNothing("role_id", "permission_id").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "db.Insert")
}
addedPerms = append(addedPerms, perm.Name.String())
}
for _, perm := range removePerms {
err := DeleteItem[RolePermission](tx).
Where("role_id = ?", r.ID).
Where("permission_id = ?", perm.ID).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteItem")
}
removedPerms = append(removedPerms, perm.Name.String())
}
// Log the permission changes
if len(addedPerms) > 0 || len(removedPerms) > 0 {
details := map[string]any{
"role_name": string(r.Name),
}
if len(addedPerms) > 0 {
details["added_permissions"] = addedPerms
}
if len(removedPerms) > 0 {
details["removed_permissions"] = removedPerms
}
info := &AuditInfo{
"roles.update_permissions",
"role",
r.ID,
details,
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
}
return nil
}
func detectChangedPermissions(ctx context.Context, tx bun.Tx, role *Role, permissionIDs []int) ([]*Permission, []*Permission, error) {
allPermissions, err := ListAllPermissions(ctx, tx)
if err != nil {
return nil, nil, errors.Wrap(err, "ListAllPermissions")
}
// Build map of current permissions
currentPermIDs := make(map[int]bool)
for _, perm := range role.Permissions {
currentPermIDs[perm.ID] = true
}
var addedPerms []*Permission
var removedPerms []*Permission
// Determine what to add and remove
for _, perm := range allPermissions {
hasNow := currentPermIDs[perm.ID]
shouldHave := slices.Contains(permissionIDs, perm.ID)
if shouldHave && !hasNow {
addedPerms = append(addedPerms, perm)
} else if !shouldHave && hasNow {
removedPerms = append(removedPerms, perm)
}
}
return addedPerms, removedPerms, nil
}

View File

@@ -9,31 +9,73 @@ import (
"github.com/uptrace/bun"
)
// SeasonStatus represents the current status of a season
type SeasonStatus string
const (
// StatusUpcoming means the season has not started yet
StatusUpcoming SeasonStatus = "upcoming"
// StatusInProgress means the regular season is active
StatusInProgress SeasonStatus = "in_progress"
// StatusFinalsSoon means regular season ended, finals upcoming
StatusFinalsSoon SeasonStatus = "finals_soon"
// StatusFinals means finals are in progress
StatusFinals SeasonStatus = "finals"
// StatusCompleted means the season has finished
StatusCompleted SeasonStatus = "completed"
)
type SeasonType string
func (s SeasonType) String() string {
return string(s)
}
const (
SeasonTypeRegular SeasonType = "regular"
SeasonTypeDraft SeasonType = "draft"
)
type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"`
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"`
Type string `bun:"type,notnull" json:"type"`
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 returns a new season. It does not add it to the database
func NewSeason(name, version, shortname string, start time.Time) *Season {
// NewSeason creats a new season
func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname, type_ string,
start time.Time, audit *AuditMeta,
) (*Season, error) {
season := &Season{
Name: name,
ShortName: strings.ToUpper(shortname),
StartDate: start.Truncate(time.Hour * 24),
SlapVersion: version,
Type: type_,
}
return season
err := Insert(tx, season).
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.WithMessage(err, "db.Insert")
}
if season.Type == SeasonTypeDraft.String() {
err = NewSeasonLeague(ctx, tx, season.ShortName, "Draft", audit)
if err != nil {
return nil, errors.Wrap(err, "NewSeasonLeague")
}
}
return season, nil
}
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Season], error) {
@@ -54,7 +96,9 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error
}
// Update updates the season struct. It does not insert to the database
func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time.Time) {
func (s *Season) Update(ctx context.Context, tx bun.Tx, version string,
start, end, finalsStart, finalsEnd time.Time, audit *AuditMeta,
) error {
s.SlapVersion = version
s.StartDate = start.Truncate(time.Hour * 24)
if !end.IsZero() {
@@ -66,6 +110,9 @@ func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time.
if !finalsEnd.IsZero() {
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24)
}
return Update(tx, s).WherePK().
Column("slap_version", "start_date", "end_date", "finals_start_date", "finals_end_date").
WithAudit(audit, nil).Exec(ctx)
}
func (s *Season) MapTeamsToLeagues(ctx context.Context, tx bun.Tx) ([]LeagueWithTeams, error) {
@@ -95,11 +142,61 @@ type LeagueWithTeams struct {
Teams []*Team
}
func (s *Season) HasLeague(leagueID int) bool {
for _, league := range s.Leagues {
if league.ID == leagueID {
// GetStatus returns the current status of the season based on dates
func (s *Season) GetStatus() SeasonStatus {
now := time.Now()
if now.Before(s.StartDate) {
return StatusUpcoming
}
if !s.FinalsStartDate.IsZero() {
if !s.FinalsEndDate.IsZero() && now.After(s.FinalsEndDate.Time) {
return StatusCompleted
}
if now.After(s.FinalsStartDate.Time) {
return StatusFinals
}
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
return StatusFinalsSoon
}
return StatusInProgress
}
if !s.EndDate.IsZero() && now.After(s.EndDate.Time) {
return StatusCompleted
}
return StatusInProgress
}
// GetDefaultTab returns the default tab to show based on the season status
func (s *Season) GetDefaultTab() string {
switch s.GetStatus() {
case StatusInProgress:
return "table"
case StatusUpcoming:
return "teams"
default:
return "finals"
}
}
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)
}

143
internal/db/seasonleague.go Normal file
View File

@@ -0,0 +1,143 @@
package db
import (
"context"
"database/sql"
"git.haelnorr.com/h/oslstats/internal/permissions"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type SeasonLeague struct {
SeasonID int `bun:",pk"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
LeagueID int `bun:",pk"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
}
// GetSeasonLeague retrieves a specific season-league combination
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*SeasonLeague, error) {
if seasonShortName == "" {
return nil, errors.New("season short_name cannot be empty")
}
if leagueShortName == "" {
return nil, errors.New("league short_name cannot be empty")
}
sl := new(SeasonLeague)
err := tx.NewSelect().
Model(sl).
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("season.short_name = ?", seasonShortName)
}).
Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("league.short_name = ?", leagueShortName)
}).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, BadRequestNotFound("season_league", "season.short_name,league.short_name", seasonShortName+","+leagueShortName)
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return sl, nil
}
// GetSeasonLeagueWithTeams retrieves a specific season-league combination with teams
func GetSeasonLeagueWithTeams(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) {
if seasonShortName == "" {
return nil, nil, nil, errors.New("season short_name cannot be empty")
}
if leagueShortName == "" {
return nil, nil, nil, errors.New("league short_name cannot be empty")
}
season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason")
}
league, err := season.GetLeague(leagueShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "season.GetLeague")
}
// Get all teams participating in this season+league
var teams []*Team
err = tx.NewSelect().
Model(&teams).
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", season.ID, league.ID).
Order("t.name ASC").
Relation("Players", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("season_id = ? AND league_id = ?", season.ID, league.ID)
}).
Scan(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "tx.Select teams")
}
return season, league, teams, nil
}
func NewSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, audit *AuditMeta) error {
season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil {
return errors.Wrap(err, "GetSeason")
}
league, err := GetLeague(ctx, tx, leagueShortName)
if err != nil {
return errors.Wrap(err, "GetLeague")
}
if season.HasLeague(league) {
return BadRequestAssociated("season", "league",
"id", "id", season.ID, league.ID)
}
seasonLeague := &SeasonLeague{
SeasonID: season.ID,
LeagueID: league.ID,
}
info := &AuditInfo{
string(permissions.SeasonsAddLeague),
"season",
season.ID,
map[string]any{"league_id": league.ID},
}
err = Insert(tx, seasonLeague).WithAudit(audit, info).Exec(ctx)
if err != nil {
return errors.Wrap(err, "db.Insert")
}
return nil
}
func (s *Season) RemoveLeague(ctx context.Context, tx bun.Tx, leagueShortName string, audit *AuditMeta) error {
league, err := s.GetLeague(leagueShortName)
if err != nil {
return errors.Wrap(err, "s.GetLeague")
}
info := &AuditInfo{
string(permissions.SeasonsRemoveLeague),
"season",
s.ID,
map[string]any{"league_id": league.ID},
}
err = DeleteItem[SeasonLeague](tx).
Where("season_id = ?", s.ID).
Where("league_id = ?", league.ID).
WithAudit(audit, info).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "db.DeleteItem")
}
return nil
}
func (t *Team) InTeams(teams []*Team) bool {
for _, team := range teams {
if t.ID == team.ID {
return true
}
}
return false
}

61
internal/db/setup.go Normal file
View File

@@ -0,0 +1,61 @@
package db
import (
"database/sql"
"fmt"
"time"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
)
type DB struct {
*bun.DB
}
func (db *DB) Close() error {
return db.DB.Close()
}
func (db *DB) RegisterModels() []any {
models := []any{
(*RolePermission)(nil),
(*UserRole)(nil),
(*SeasonLeague)(nil),
(*TeamParticipation)(nil),
(*TeamRoster)(nil),
(*User)(nil),
(*DiscordToken)(nil),
(*Season)(nil),
(*League)(nil),
(*Team)(nil),
(*Role)(nil),
(*Permission)(nil),
(*AuditLog)(nil),
(*Fixture)(nil),
(*FixtureSchedule)(nil),
(*Player)(nil),
(*FixtureResult)(nil),
(*FixtureResultPlayerStats)(nil),
}
db.RegisterModel(models...)
return models
}
func NewDB(cfg *Config) *DB {
dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DB, cfg.SSL)
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
sqldb.SetMaxOpenConns(25)
sqldb.SetMaxIdleConns(10)
sqldb.SetConnMaxLifetime(5 * time.Minute)
sqldb.SetConnMaxIdleTime(5 * time.Minute)
db := &DB{
bun.NewDB(sqldb, pgdialect.New()),
}
db.RegisterModels()
return db
}

View File

@@ -9,23 +9,30 @@ 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:"-"`
Players []Player `bun:"m2m:team_rosters,join:Team=Player" json:"-"`
}
type TeamParticipation struct {
SeasonID int `bun:",pk,unique:season_team"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
LeagueID int `bun:",pk"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
TeamID int `bun:",pk,unique:season_team"`
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {
team := &Team{
Name: name,
ShortName: shortName,
AltShortName: altShortName,
Color: color,
}
err := Insert(tx, team).
WithAudit(audit, nil).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "db.Insert")
}
return team, nil
}
func ListTeams(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Team], error) {
@@ -45,14 +52,23 @@ func GetTeam(ctx context.Context, tx bun.Tx, id int) (*Team, error) {
return GetByID[Team](tx, id).Relation("Seasons").Relation("Leagues").Get(ctx)
}
func TeamShortNamesUnique(ctx context.Context, tx bun.Tx, shortname, altshortname string) (bool, error) {
func TeamShortNamesUnique(ctx context.Context, tx bun.Tx, shortName, altShortName string) (bool, error) {
// Check if this combination of short_name and alt_short_name exists
count, err := tx.NewSelect().
Model((*Team)(nil)).
Where("short_name = ? AND alt_short_name = ?", shortname, altshortname).
Where("short_name = ? AND alt_short_name = ?", shortName, altShortName).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.Select")
}
return count == 0, nil
}
func (t *Team) InSeason(seasonID int) bool {
for _, season := range t.Seasons {
if season.ID == seasonID {
return true
}
}
return false
}

View File

@@ -0,0 +1,56 @@
package db
import (
"context"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type TeamParticipation struct {
SeasonID int `bun:",pk,unique:season_team"`
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
LeagueID int `bun:",pk"`
League *League `bun:"rel:belongs-to,join:league_id=id"`
TeamID int `bun:",pk,unique:season_team"`
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
}
func NewTeamParticipation(ctx context.Context, tx bun.Tx,
seasonShortName, leagueShortName string, teamID int, audit *AuditMeta,
) (*Team, *Season, *League, error) {
season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason")
}
league, err := season.GetLeague(leagueShortName)
if err != nil {
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",
"id", "id", season.ID, team.ID)
}
participation := &TeamParticipation{
SeasonID: season.ID,
LeagueID: league.ID,
TeamID: team.ID,
}
info := &AuditInfo{
"teams.join_season",
"team",
teamID,
map[string]any{"season_id": season.ID, "league_id": league.ID},
}
err = Insert(tx, participation).
WithAudit(audit, info).Exec(ctx)
if err != nil {
return nil, nil, nil, errors.Wrap(err, "db.Insert")
}
return team, season, league, nil
}

247
internal/db/teamroster.go Normal file
View File

@@ -0,0 +1,247 @@
package db
import (
"context"
"fmt"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type TeamRoster struct {
bun.BaseModel `bun:"table:team_rosters,alias:tr"`
TeamID int `bun:",pk,notnull" json:"team_id"`
SeasonID int `bun:",pk,notnull,unique:player" json:"season_id"`
LeagueID int `bun:",pk,notnull,unique:player" json:"league_id"`
PlayerID int `bun:",pk,notnull,unique:player" json:"player_id"`
IsManager bool `bun:"is_manager,default:'false'" json:"is_manager"`
Team *Team `bun:"rel:belongs-to,join:team_id=id" json:"-"`
Player *Player `bun:"rel:belongs-to,join:player_id=id" json:"-"`
Season *Season `bun:"rel:belongs-to,join:season_id=id" json:"-"`
League *League `bun:"rel:belongs-to,join:league_id=id" json:"-"`
}
type TeamWithRoster struct {
Team *Team
Season *Season
League *League
Manager *Player
Players []*Player
}
func GetTeamRoster(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string, teamID int) (*TeamWithRoster, error) {
tr := []*TeamRoster{}
err := tx.NewSelect().
Model(&tr).
Relation("Team", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("team.id = ?", teamID)
}).
Relation("Season", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("season.short_name = ?", seasonShortName)
}).
Relation("League", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("league.short_name = ?", leagueShortName)
}).
Relation("Player").Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
team, err := GetTeam(ctx, tx, teamID)
if err != nil {
return nil, errors.Wrap(err, "GetTeam")
}
sl, err := GetSeasonLeague(ctx, tx, seasonShortName, leagueShortName)
if err != nil {
return nil, errors.Wrap(err, "GetSeasonLeague")
}
var manager *Player
players := []*Player{}
for _, tp := range tr {
if tp.IsManager {
manager = tp.Player
} else {
players = append(players, tp.Player)
}
}
players = append([]*Player{manager}, players...)
twr := &TeamWithRoster{
team,
sl.Season,
sl.League,
manager,
players,
}
return twr, nil
}
// GetManagersByTeam returns a map of teamID -> manager Player for all teams in a season/league
func GetManagersByTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID int) (map[int]*Player, error) {
rosters := []*TeamRoster{}
err := tx.NewSelect().
Model(&rosters).
Where("tr.season_id = ?", seasonID).
Where("tr.league_id = ?", leagueID).
Where("tr.is_manager = true").
Relation("Player").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
result := make(map[int]*Player, len(rosters))
for _, r := range rosters {
result[r.TeamID] = r.Player
}
return result, nil
}
func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, playerID int, manager bool, audit *AuditMeta) error {
season, err := GetByID[Season](tx, seasonID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetSeason")
}
league, err := GetByID[League](tx, leagueID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetLeague")
}
team, err := GetByID[Team](tx, teamID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetTeam")
}
player, err := GetByID[Player](tx, playerID).Get(ctx)
if err != nil {
return errors.Wrap(err, "GetPlayer")
}
tr := &TeamRoster{
SeasonID: season.ID,
LeagueID: league.ID,
TeamID: team.ID,
PlayerID: player.ID,
IsManager: manager,
}
err = Insert(tx, tr).WithAudit(audit, nil).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert")
}
return nil
}
// ManageTeamRoster replaces the entire roster for a team in a season/league.
// It deletes all existing roster entries and inserts the new ones.
// Also auto-removes free agent registrations and nominations for players joining a team.
func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, managerID int, playerIDs []int, audit *AuditMeta) error {
// Delete all existing roster entries for this team/season/league
_, err := tx.NewDelete().
Model((*TeamRoster)(nil)).
Where("season_id = ?", seasonID).
Where("league_id = ?", leagueID).
Where("team_id = ?", teamID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "delete existing roster")
}
// Collect all player IDs being added (including manager)
allPlayerIDs := make([]int, 0, len(playerIDs)+1)
if managerID > 0 {
allPlayerIDs = append(allPlayerIDs, managerID)
}
for _, pid := range playerIDs {
if pid != managerID {
allPlayerIDs = append(allPlayerIDs, pid)
}
}
// Auto-remove free agent registrations and nominations for players joining a team
for _, playerID := range allPlayerIDs {
// Check if the player is a registered free agent
isFA, err := IsFreeAgentRegistered(ctx, tx, seasonID, leagueID, playerID)
if err != nil {
return errors.Wrap(err, "IsFreeAgentRegistered")
}
if isFA {
// Remove all nominations for this player
err = RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
}
// Remove free agent registration
err = RemoveFreeAgentRegistrationForPlayer(ctx, tx, playerID)
if err != nil {
return errors.Wrap(err, "RemoveFreeAgentRegistrationForPlayer")
}
// Log the cascade action
if audit != nil {
cascadeInfo := &AuditInfo{
"free_agents.auto_removed_on_team_join",
"season_league_free_agent",
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"player_id": playerID,
"team_id": teamID,
},
}
err = LogSuccess(ctx, tx, audit, cascadeInfo)
if err != nil {
return errors.Wrap(err, "LogSuccess cascade")
}
}
}
}
// Insert manager if provided
if managerID > 0 {
tr := &TeamRoster{
SeasonID: seasonID,
LeagueID: leagueID,
TeamID: teamID,
PlayerID: managerID,
IsManager: true,
}
err = Insert(tx, tr).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert manager")
}
}
// Insert players
for _, playerID := range playerIDs {
if playerID == managerID {
continue // Already inserted as manager
}
tr := &TeamRoster{
SeasonID: seasonID,
LeagueID: leagueID,
TeamID: teamID,
PlayerID: playerID,
IsManager: false,
}
err = Insert(tx, tr).Exec(ctx)
if err != nil {
return errors.Wrap(err, "Insert player")
}
}
// Log the roster change
details := map[string]any{
"season_id": seasonID,
"league_id": leagueID,
"team_id": teamID,
"manager_id": managerID,
"player_ids": playerIDs,
}
info := &AuditInfo{
"teams.manage_players",
"team_roster",
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, teamID),
details,
}
err = LogSuccess(ctx, tx, audit, info)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
return nil
}

View File

@@ -22,16 +22,15 @@ var timeout = 15 * time.Second
// WithReadTx executes a read-only transaction with automatic rollback
// Returns true if successful, false if error was thrown to client
func WithReadTx(
func (db *DB) WithReadTx(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
conn *bun.DB,
fn TxFunc,
) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
ok, err := withTx(ctx, conn, fn, false)
ok, err := db.withTx(ctx, fn, false)
if err != nil {
throw.InternalServiceError(s, w, r, "Database error", err)
}
@@ -41,31 +40,29 @@ func WithReadTx(
// WithTxFailSilently executes a transaction with automatic rollback
// Returns true if successful, false if error occured.
// Does not throw any errors to the client.
func WithTxFailSilently(
func (db *DB) WithTxFailSilently(
ctx context.Context,
conn *bun.DB,
fn TxFuncSilent,
) error {
fnc := func(ctx context.Context, tx bun.Tx) (bool, error) {
err := fn(ctx, tx)
return err == nil, err
}
_, err := withTx(ctx, conn, fnc, true)
_, err := db.withTx(ctx, fnc, true)
return err
}
// WithWriteTx executes a write transaction with automatic rollback on error
// Commits only if fn returns nil. Returns true if successful.
func WithWriteTx(
func (db *DB) WithWriteTx(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
conn *bun.DB,
fn TxFunc,
) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
ok, err := withTx(ctx, conn, fn, true)
ok, err := db.withTx(ctx, fn, true)
if err != nil {
throw.InternalServiceError(s, w, r, "Database error", err)
}
@@ -74,16 +71,15 @@ func WithWriteTx(
// WithNotifyTx executes a transaction with notification-based error handling
// Uses notifyInternalServiceError instead of throwInternalServiceError
func WithNotifyTx(
func (db *DB) WithNotifyTx(
s *hws.Server,
w http.ResponseWriter,
r *http.Request,
conn *bun.DB,
fn TxFunc,
) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
ok, err := withTx(ctx, conn, fn, true)
ok, err := db.withTx(ctx, fn, true)
if err != nil {
notify.InternalServiceError(s, w, r, "Database error", err)
}
@@ -91,13 +87,12 @@ func WithNotifyTx(
}
// withTx executes a transaction with automatic rollback on error
func withTx(
func (db *DB) withTx(
ctx context.Context,
conn *bun.DB,
fn TxFunc,
write bool,
) (bool, error) {
tx, err := conn.BeginTx(ctx, nil)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return false, errors.Wrap(err, "conn.BeginTx")
}

View File

@@ -2,19 +2,18 @@ package db
import (
"context"
"net/http"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type updater[T any] struct {
tx bun.Tx
q *bun.UpdateQuery
model *T
columns []string
auditCallback AuditCallback
auditRequest *http.Request
tx bun.Tx
q *bun.UpdateQuery
model *T
columns []string
audit *AuditMeta
auditInfo *AuditInfo
}
// Update creates an updater for a model
@@ -69,11 +68,10 @@ func (u *updater[T]) Set(query string, args ...any) *updater[T] {
}
// WithAudit enables audit logging for this update operation
// The callback will be invoked after successful update with auto-generated audit info
// If the callback returns an error, the transaction will be rolled back
func (u *updater[T]) WithAudit(r *http.Request, callback AuditCallback) *updater[T] {
u.auditRequest = r
u.auditCallback = callback
// If the provided *AuditInfo is nil, will use reflection to automatically work out the details
func (u *updater[T]) WithAudit(meta *AuditMeta, info *AuditInfo) *updater[T] {
u.audit = meta
u.auditInfo = info
return u
}
@@ -82,32 +80,41 @@ func (u *updater[T]) WithAudit(r *http.Request, callback AuditCallback) *updater
func (u *updater[T]) Exec(ctx context.Context) error {
// Build audit details BEFORE update (captures changed fields)
var details map[string]any
if u.auditCallback != nil && len(u.columns) > 0 {
if u.audit != nil && len(u.columns) > 0 {
details = extractChangedFields(u.model, u.columns)
}
// Execute update
_, err := u.q.Exec(ctx)
result, err := u.q.Exec(ctx)
if err != nil {
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
if u.auditCallback != nil && u.auditRequest != nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "update")
if u.audit != nil {
if u.auditInfo == nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "update")
info := &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: extractPrimaryKey(u.model),
Details: details, // Changed fields only
u.auditInfo = &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: extractPrimaryKey(u.model),
Details: details, // Changed fields only
}
}
// Call audit callback - if it fails, return error to trigger rollback
if err := u.auditCallback(ctx, u.tx, info, u.auditRequest); err != nil {
return errors.Wrap(err, "audit.callback")
err = LogSuccess(ctx, u.tx, u.audit, u.auditInfo)
if err != nil {
return errors.Wrap(err, "LogSuccess")
}
}

View File

@@ -12,25 +12,26 @@ import (
"github.com/uptrace/bun"
)
var CurrentUser hwsauth.ContextLoader[*User]
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:"-"`
Player *Player `bun:"rel:has-one,join:id=user_id"`
}
func (u *User) GetID() int {
return u.ID
}
var CurrentUser hwsauth.ContextLoader[*User]
// CreateUser creates a new user with the given username and password
func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) {
func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User, audit *AuditMeta) (*User, error) {
if discorduser == nil {
return nil, errors.New("user cannot be nil")
}
@@ -39,8 +40,10 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
CreatedAt: time.Now().Unix(),
DiscordID: discorduser.ID,
}
audit.u = user
err := Insert(tx, user).
WithAudit(audit, nil).
Returning("id").
Exec(ctx)
if err != nil {
@@ -51,27 +54,27 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
}
// 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) {
return GetByID[User](tx, id).Get(ctx)
return GetByID[User](tx, id).Relation("Player").Get(ctx)
}
// GetUserByUsername queries the database for a user matching the given username
// 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) {
if username == "" {
return nil, errors.New("username not provided")
}
return GetByField[User](tx, "username", username).Get(ctx)
return GetByField[User](tx, "username", username).Relation("Player").Get(ctx)
}
// GetUserByDiscordID queries the database for a user matching the given discord id
// 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) {
if discordID == "" {
return nil, errors.New("discord_id not provided")
}
return GetByField[User](tx, "discord_id", discordID).Get(ctx)
return GetByField[User](tx, "u.discord_id", discordID).Relation("Player").Get(ctx)
}
// GetRoles loads all the roles for this user
@@ -139,7 +142,7 @@ func (u *User) IsAdmin(ctx context.Context, tx bun.Tx) (bool, error) {
func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) {
defaults := &PageOpts{1, 50, bun.OrderAsc, "id"}
return GetList[User](tx).GetPaged(ctx, pageOpts, defaults)
return GetList[User](tx).Relation("Player").GetPaged(ctx, pageOpts, defaults)
}
// GetUsersWithRoles queries the database for users with their roles preloaded

View File

@@ -3,6 +3,7 @@ package db
import (
"context"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/roles"
"github.com/pkg/errors"
"github.com/uptrace/bun"
@@ -16,7 +17,7 @@ type UserRole struct {
}
// AssignRole grants a role to a user
func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int, audit *AuditMeta) error {
if userID <= 0 {
return errors.New("userID must be positive")
}
@@ -28,8 +29,20 @@ func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
UserID: userID,
RoleID: roleID,
}
details := map[string]any{
"action": "grant",
"role_id": roleID,
}
info := &AuditInfo{
string(permissions.UsersManageRoles),
"user",
userID,
details,
}
err := Insert(tx, userRole).
ConflictNothing("user_id", "role_id").Exec(ctx)
ConflictNothing("user_id", "role_id").
WithAudit(audit, info).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "db.Insert")
}
@@ -38,7 +51,7 @@ func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
}
// RevokeRole removes a role from a user
func RevokeRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
func RevokeRole(ctx context.Context, tx bun.Tx, userID, roleID int, audit *AuditMeta) error {
if userID <= 0 {
return errors.New("userID must be positive")
}
@@ -46,9 +59,20 @@ func RevokeRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
return errors.New("roleID must be positive")
}
details := map[string]any{
"action": "revoke",
"role_id": roleID,
}
info := &AuditInfo{
string(permissions.UsersManageRoles),
"user",
userID,
details,
}
err := DeleteItem[UserRole](tx).
Where("user_id = ?", userID).
Where("role_id = ?", roleID).
WithAudit(audit, info).
Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteItem")
@@ -70,9 +94,6 @@ func HasRole(ctx context.Context, tx bun.Tx, userID int, roleName roles.Role) (b
if err != nil {
return false, errors.Wrap(err, "GetByID")
}
if user == nil {
return false, nil
}
for _, role := range user.Roles {
if role.Name == roleName {
return true, nil

View File

@@ -1,3 +1,4 @@
// Package discord provides utilities for interacting with the discord API
package discord
import (
@@ -8,11 +9,13 @@ import (
)
type Config struct {
ClientID string // ENV DISCORD_CLIENT_ID: Discord application client ID (required)
ClientSecret string // ENV DISCORD_CLIENT_SECRET: Discord application client secret (required)
OAuthScopes string // Authorisation scopes for OAuth
RedirectPath string // ENV DISCORD_REDIRECT_PATH: Path for the OAuth redirect handler (required)
BotToken string // ENV DISCORD_BOT_TOKEN: Token for the discord bot (required)
ClientID string `ezconf:"DISCORD_CLIENT_ID,required,description:Discord application client ID"`
ClientSecret string `ezconf:"DISCORD_CLIENT_SECRET,required,description:Discord application client secret"`
RedirectPath string `ezconf:"DISCORD_REDIRECT_PATH,required,description:Path for the OAuth redirect handler"`
BotToken string `ezconf:"DISCORD_BOT_TOKEN,required,description:Token for the discord bot"`
GuildID string `ezconf:"DISCORD_GUILD_ID,required,description:ID for the discord server the bot should connect to"`
OAuthScopes string // Authorisation scopes for OAuth
}
func ConfigFromEnv() (any, error) {
@@ -22,6 +25,7 @@ func ConfigFromEnv() (any, error) {
OAuthScopes: getOAuthScopes(),
RedirectPath: env.String("DISCORD_REDIRECT_PATH", ""),
BotToken: env.String("DISCORD_BOT_TOKEN", ""),
GuildID: env.String("DISCORD_GUILD_ID", ""),
}
// Check required fields
@@ -37,6 +41,9 @@ func ConfigFromEnv() (any, error) {
if cfg.BotToken == "" {
return nil, errors.New("Envar not set: DISCORD_BOT_TOKEN")
}
if cfg.GuildID == "" {
return nil, errors.New("Envar not set: DISCORD_GUILD_ID")
}
return cfg, nil
}

View File

@@ -1,41 +1,11 @@
package discord
import (
"runtime"
"strings"
"git.haelnorr.com/h/golib/ezconf"
)
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct {
configFunc func() (any, error)
name string
}
// PackagePath returns the path to the config package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return e.configFunc()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return strings.ToLower(e.name)
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return e.name
}
// NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration {
return EZConfIntegration{name: "Discord", configFunc: ConfigFromEnv}
func NewEZConfIntegration() *ezconf.Integration {
return ezconf.NewIntegration("discord", "Discord", &Config{},
func() (any, error) { return ConfigFromEnv() })
}

View File

@@ -19,19 +19,19 @@ type RateLimitState struct {
// Do executes an HTTP request with automatic rate limit handling
// It will wait if rate limits are about to be exceeded and retry once if a 429 is received
func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
func (api *APIClient) Do(req *http.Request) (*http.Response, error) {
if req == nil {
return nil, errors.New("request cannot be nil")
}
// Step 1: Check if we need to wait before making request
bucket := c.getBucketFromRequest(req)
if err := c.waitIfNeeded(bucket); err != nil {
bucket := api.getBucketFromRequest(req)
if err := api.waitIfNeeded(bucket); err != nil {
return nil, err
}
// Step 2: Execute request
resp, err := c.client.Do(req)
resp, err := api.client.Do(req)
if err != nil {
// Check if it's a network timeout
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
@@ -41,17 +41,17 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
}
// Step 3: Update rate limit state from response headers
c.updateRateLimit(resp.Header)
api.updateRateLimit(resp.Header)
// Step 4: Handle 429 (rate limited)
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() // Close original response
retryAfter := c.parseRetryAfter(resp.Header)
retryAfter := api.parseRetryAfter(resp.Header)
// No Retry-After header, can't retry safely
if retryAfter == 0 {
c.logger.Warn().
api.logger.Warn().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -61,7 +61,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// Retry-After exceeds 30 second cap
if retryAfter > 30*time.Second {
c.logger.Warn().
api.logger.Warn().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
}
// Wait and retry
c.logger.Warn().
api.logger.Warn().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
time.Sleep(retryAfter)
// Retry the request
resp, err = c.client.Do(req)
resp, err = api.client.Do(req)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return nil, errors.Wrap(err, "retry request timed out")
@@ -93,12 +93,12 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
}
// Update rate limit again after retry
c.updateRateLimit(resp.Header)
api.updateRateLimit(resp.Header)
// If STILL rate limited after retry, return error
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
c.logger.Error().
api.logger.Error().
Str("bucket", bucket).
Str("method", req.Method).
Str("path", req.URL.Path).
@@ -115,15 +115,15 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// getBucketFromRequest extracts or generates bucket ID from request
// For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers
func (c *APIClient) getBucketFromRequest(req *http.Request) string {
func (api *APIClient) getBucketFromRequest(req *http.Request) string {
return req.Method + ":" + req.URL.Path
}
// waitIfNeeded checks if we need to delay before request to avoid hitting rate limits
func (c *APIClient) waitIfNeeded(bucket string) error {
c.mu.RLock()
state, exists := c.buckets[bucket]
c.mu.RUnlock()
func (api *APIClient) waitIfNeeded(bucket string) error {
api.mu.RLock()
state, exists := api.buckets[bucket]
api.mu.RUnlock()
if !exists {
return nil // No state yet, proceed
@@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
waitDuration += 100 * time.Millisecond
if waitDuration > 0 {
c.logger.Debug().
api.logger.Debug().
Str("bucket", bucket).
Dur("wait_duration", waitDuration).
Msg("Proactively waiting for rate limit reset")
@@ -150,16 +150,16 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
}
// updateRateLimit parses response headers and updates bucket state
func (c *APIClient) updateRateLimit(headers http.Header) {
func (api *APIClient) updateRateLimit(headers http.Header) {
bucket := headers.Get("X-RateLimit-Bucket")
if bucket == "" {
return // No bucket info, can't track
}
// Parse headers
limit := c.parseInt(headers.Get("X-RateLimit-Limit"))
remaining := c.parseInt(headers.Get("X-RateLimit-Remaining"))
resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After"))
limit := api.parseInt(headers.Get("X-RateLimit-Limit"))
remaining := api.parseInt(headers.Get("X-RateLimit-Remaining"))
resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After"))
state := &RateLimitState{
Bucket: bucket,
@@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))),
}
c.mu.Lock()
c.buckets[bucket] = state
c.mu.Unlock()
api.mu.Lock()
api.buckets[bucket] = state
api.mu.Unlock()
// Log rate limit state for debugging
c.logger.Debug().
api.logger.Debug().
Str("bucket", bucket).
Int("remaining", remaining).
Int("limit", limit).
@@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
}
// parseRetryAfter extracts retry delay from Retry-After header
func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration {
func (api *APIClient) parseRetryAfter(headers http.Header) time.Duration {
retryAfter := headers.Get("Retry-After")
if retryAfter == "" {
return 0
}
// Discord returns seconds as float
seconds := c.parseFloat(retryAfter)
seconds := api.parseFloat(retryAfter)
if seconds <= 0 {
return 0
}
@@ -198,7 +198,7 @@ func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration {
}
// parseInt parses an integer from a header value, returns 0 on error
func (c *APIClient) parseInt(s string) int {
func (api *APIClient) parseInt(s string) int {
if s == "" {
return 0
}
@@ -207,7 +207,7 @@ func (c *APIClient) parseInt(s string) int {
}
// parseFloat parses a float from a header value, returns 0 on error
func (c *APIClient) parseFloat(s string) float64 {
func (api *APIClient) parseFloat(s string) float64 {
if s == "" {
return 0
}

View File

@@ -0,0 +1,20 @@
package discord
import (
"github.com/pkg/errors"
)
var ErrNoSteam error = errors.New("steam connection not found")
func (s *OAuthSession) GetSteamID() (string, error) {
connections, err := s.UserConnections()
if err != nil {
return "", errors.Wrap(err, "s.UserConnections")
}
for _, conn := range connections {
if conn.Type == "steam" {
return conn.ID, nil
}
}
return "", ErrNoSteam
}

View File

@@ -12,10 +12,10 @@ import (
var embeddedFiles embed.FS
// GetEmbeddedFS gets the embedded files
func GetEmbeddedFS() (fs.FS, error) {
func GetEmbeddedFS() (*fs.FS, error) {
subFS, err := fs.Sub(embeddedFiles, "web")
if err != nil {
return nil, errors.Wrap(err, "fs.Sub")
}
return subFS, nil
return &subFS, nil
}

View File

@@ -0,0 +1,151 @@
/* Flatpickr Catppuccin Mocha Theme */
/* Override flatpickr colors to match our custom theme */
.flatpickr-calendar {
background: #1e1e2e; /* mantle */
border: 1px solid #45475a; /* surface1 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.flatpickr-months {
background: #181825; /* base */
border-bottom: 1px solid #45475a; /* surface1 */
}
.flatpickr-month {
color: #cdd6f4; /* text */
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
background: #1e1e2e; /* mantle */
color: #cdd6f4; /* text */
border: 1px solid #45475a; /* surface1 */
}
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
background: #313244; /* surface0 */
}
.flatpickr-current-month input.cur-year {
color: #cdd6f4; /* text */
background: #1e1e2e; /* mantle */
}
.flatpickr-current-month input.cur-year:hover {
background: #313244; /* surface0 */
}
.flatpickr-prev-month,
.flatpickr-next-month {
color: #cdd6f4; /* text */
}
.flatpickr-prev-month:hover,
.flatpickr-next-month:hover {
color: #89b4fa; /* blue */
}
.flatpickr-weekdays {
background: #181825; /* base */
border-bottom: 1px solid #45475a; /* surface1 */
}
span.flatpickr-weekday {
color: #bac2de; /* subtext0 */
font-weight: 600;
}
.flatpickr-days {
background: #1e1e2e; /* mantle */
}
.flatpickr-day {
color: #cdd6f4; /* text */
border: 1px solid transparent;
}
.flatpickr-day.today {
border-color: #89b4fa; /* blue */
background: #89b4fa20; /* blue with transparency */
color: #89b4fa; /* blue */
}
.flatpickr-day.today:hover {
background: #89b4fa40; /* blue with more transparency */
border-color: #89b4fa; /* blue */
color: #89b4fa; /* blue */
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange {
background: #89b4fa; /* blue */
border-color: #89b4fa; /* blue */
color: #181825; /* base */
}
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover {
background: #74a7f9; /* slightly lighter blue */
border-color: #74a7f9;
}
.flatpickr-day:hover {
background: #313244; /* surface0 */
border-color: #45475a; /* surface1 */
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: #585b70; /* surface2 */
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: #585b70; /* surface2 */
cursor: not-allowed;
}
.flatpickr-day.inRange {
background: #89b4fa30; /* blue with light transparency */
border-color: transparent;
box-shadow: -5px 0 0 #89b4fa30, 5px 0 0 #89b4fa30;
}
.flatpickr-time {
background: #181825; /* base */
border-top: 1px solid #45475a; /* surface1 */
}
.flatpickr-time input {
color: #cdd6f4; /* text */
background: #1e1e2e; /* mantle */
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: #313244; /* surface0 */
}
.flatpickr-time .flatpickr-time-separator,
.flatpickr-time .flatpickr-am-pm {
color: #cdd6f4; /* text */
}
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time .flatpickr-am-pm:focus {
background: #313244; /* surface0 */
}
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #cdd6f4; /* text */
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #cdd6f4; /* text */
}
.flatpickr-time .numInputWrapper span:hover {
background: #313244; /* surface0 */
}

View File

@@ -127,3 +127,74 @@
font-weight: 700;
font-style: italic;
}
/* Custom Scrollbar Styles - Catppuccin Theme */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--surface1) var(--mantle);
}
/* Webkit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--mantle);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
border: 2px solid var(--mantle);
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
}
::-webkit-scrollbar-thumb:active {
background: var(--overlay0);
}
/* Specific styling for multi-select dropdowns */
.multi-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.multi-select-dropdown::-webkit-scrollbar-track {
background: var(--base);
border-radius: 3px;
}
.multi-select-dropdown::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 3px;
border: 1px solid var(--base);
}
.multi-select-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--overlay0);
}
/* Specific styling for modal content */
.modal-scrollable::-webkit-scrollbar {
width: 8px;
}
.modal-scrollable::-webkit-scrollbar-track {
background: var(--base);
}
.modal-scrollable::-webkit-scrollbar-thumb {
background: var(--surface1);
border-radius: 4px;
}
.modal-scrollable::-webkit-scrollbar-thumb:hover {
background: var(--surface2);
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,25 +10,253 @@ function formatJSON(json) {
}
}
// Handle HTMX navigation for admin sections
document.addEventListener("DOMContentLoaded", function () {
// Update active nav item after HTMX navigation
document.body.addEventListener("htmx:afterSwap", function (event) {
if (event.detail.target.id === "admin-content") {
// Get the current URL path
const path = window.location.pathname;
const section = path.split("/").pop() || "users";
// Initialize flatpickr for all date inputs
function initFlatpickr() {
document.querySelectorAll(".flatpickr-date").forEach(function (input) {
if (!input._flatpickr) {
flatpickr(input, {
dateFormat: "d/m/Y",
allowInput: true,
});
}
});
}
// Update active state on nav items
document.querySelectorAll("nav a").forEach(function (link) {
const href = link.getAttribute("href");
if (href && href.includes("/" + section)) {
link.classList.remove("text-subtext0", "hover:bg-surface1", "hover:text-text");
link.classList.add("bg-blue", "text-mantle", "font-semibold");
} else {
link.classList.remove("bg-blue", "text-mantle", "font-semibold");
link.classList.add("text-subtext0", "hover:bg-surface1", "hover:text-text");
}
// Submit the audit filter form with specific page/perPage/order params
function submitAuditFilter(page, perPage, order, orderBy) {
const form = document.getElementById("audit-filters-form");
if (!form) return;
// Create hidden inputs for pagination/sorting if they don't exist
let pageInput = form.querySelector('input[name="page"]');
if (!pageInput) {
pageInput = document.createElement("input");
pageInput.type = "hidden";
pageInput.name = "page";
form.appendChild(pageInput);
}
pageInput.value = page;
let perPageInput = form.querySelector('input[name="per_page"]');
if (!perPageInput) {
perPageInput = document.createElement("input");
perPageInput.type = "hidden";
perPageInput.name = "per_page";
form.appendChild(perPageInput);
}
perPageInput.value = perPage;
let orderInput = form.querySelector('input[name="order"]');
if (!orderInput) {
orderInput = document.createElement("input");
orderInput.type = "hidden";
orderInput.name = "order";
form.appendChild(orderInput);
}
orderInput.value = order;
let orderByInput = form.querySelector('input[name="order_by"]');
if (!orderByInput) {
orderByInput = document.createElement("input");
orderByInput.type = "hidden";
orderByInput.name = "order_by";
form.appendChild(orderByInput);
}
orderByInput.value = orderBy;
htmx.trigger(form, "submit");
}
// Sort by column - toggle direction if same column
function sortAuditColumn(field, currentOrder, currentOrderBy) {
const page = 1; // Reset to first page when sorting
const perPageSelect = document.getElementById("per-page-select");
const perPage = perPageSelect ? parseInt(perPageSelect.value) || 25 : 25;
let newOrder, newOrderBy;
if (currentOrderBy === field) {
// Toggle order
newOrder = currentOrder === "ASC" ? "DESC" : "ASC";
newOrderBy = field;
} else {
// New column, default to DESC
newOrder = "DESC";
newOrderBy = field;
}
submitAuditFilter(page, perPage, newOrder, newOrderBy);
}
// Clear all audit filters
function clearAuditFilters() {
const form = document.getElementById("audit-filters-form");
if (!form) return;
form.reset();
// Clear flatpickr instances
document.querySelectorAll(".flatpickr-date").forEach(function (input) {
var fp = input._flatpickr;
if (fp) fp.clear();
});
// Clear multi-select dropdowns
document.querySelectorAll(".multi-select-container").forEach(function (container) {
var hiddenInput = container.querySelector('input[type="hidden"]');
if (hiddenInput) hiddenInput.value = "";
var selectedDisplay = container.querySelector(".multi-select-selected");
if (selectedDisplay)
selectedDisplay.innerHTML = '<span class="text-subtext1">Select...</span>';
container.querySelectorAll(".multi-select-option").forEach(function (opt) {
opt.classList.remove("bg-blue", "text-mantle");
opt.classList.add("hover:bg-surface1");
});
});
// Trigger form submission with reset pagination
submitAuditFilter(1, 25, "DESC", "created_at");
}
// Toggle multi-select dropdown visibility
function toggleMultiSelect(containerId) {
var dropdown = document.getElementById(containerId + "-dropdown");
if (dropdown) {
dropdown.classList.toggle("hidden");
}
}
// Toggle multi-select option selection
function toggleMultiSelectOption(containerId, value, label) {
var container = document.getElementById(containerId);
var hiddenInput = container.querySelector('input[type="hidden"]');
var selectedDisplay = container.querySelector(".multi-select-selected");
var values = hiddenInput.value ? hiddenInput.value.split(",") : [];
var index = values.indexOf(value);
if (index > -1) {
values.splice(index, 1);
} else {
values.push(value);
}
hiddenInput.value = values.join(",");
var option = container.querySelector('[data-value="' + value + '"]');
if (option) {
if (index > -1) {
option.classList.remove("bg-blue", "text-mantle");
option.classList.add("hover:bg-surface1");
} else {
option.classList.add("bg-blue", "text-mantle");
option.classList.remove("hover:bg-surface1");
}
}
if (values.length === 0) {
selectedDisplay.innerHTML = '<span class="text-subtext1">Select...</span>';
} else if (values.length === 1) {
selectedDisplay.innerHTML = "<span>" + label + "</span>";
} else {
selectedDisplay.innerHTML = "<span>" + values.length + " selected</span>";
}
// Trigger form submission
document.getElementById("audit-filters-form").requestSubmit();
}
// Submit the users page with specific page/perPage/order params
function submitUsersPage(page, perPage, order, orderBy) {
const formData = new FormData();
formData.append("page", page);
formData.append("per_page", perPage);
formData.append("order", order);
formData.append("order_by", orderBy);
htmx.ajax("POST", "/admin/users", {
target: "#users-list-container",
swap: "outerHTML",
values: Object.fromEntries(formData),
});
}
// Sort users column - toggle direction if same column
function sortUsersColumn(field, currentOrder, currentOrderBy) {
const page = 1; // Reset to first page when sorting
const perPageSelect = document.getElementById("users-per-page-select");
const perPage = perPageSelect ? parseInt(perPageSelect.value) || 25 : 25;
let newOrder, newOrderBy;
if (currentOrderBy === field) {
// Toggle order
newOrder = currentOrder === "ASC" ? "DESC" : "ASC";
newOrderBy = field;
} else {
// New column, default to ASC
newOrder = "ASC";
newOrderBy = field;
}
submitUsersPage(page, perPage, newOrder, newOrderBy);
}
// Submit the roles page with specific page/perPage/order params
function submitRolesPage(page, perPage, order, orderBy) {
const formData = new FormData();
formData.append("page", page);
formData.append("per_page", perPage);
formData.append("order", order);
formData.append("order_by", orderBy);
htmx.ajax("POST", "/admin/roles", {
target: "#roles-list-container",
swap: "outerHTML",
values: Object.fromEntries(formData),
});
}
// Sort roles column - toggle direction if same column
function sortRolesColumn(field, currentOrder, currentOrderBy) {
const page = 1; // Reset to first page when sorting
const perPageSelect = document.getElementById("roles-per-page-select");
const perPage = perPageSelect ? parseInt(perPageSelect.value) || 25 : 25;
let newOrder, newOrderBy;
if (currentOrderBy === field) {
// Toggle order
newOrder = currentOrder === "ASC" ? "DESC" : "ASC";
newOrderBy = field;
} else {
// New column, default to ASC
newOrder = "ASC";
newOrderBy = field;
}
submitRolesPage(page, perPage, newOrder, newOrderBy);
}
// Handle HTMX navigation and initialization
// Tab navigation active state is handled by tabs.js (generic).
// This file only handles admin-specific concerns (flatpickr, multi-select).
document.addEventListener("DOMContentLoaded", function () {
// Initialize flatpickr on page load
initFlatpickr();
document.body.addEventListener("htmx:afterSwap", function (event) {
// Re-initialize flatpickr after admin content swap
if (
event.detail.target.id === "admin-content" ||
event.detail.target.id === "audit-results-container"
) {
initFlatpickr();
}
});
// Close multi-select dropdowns when clicking outside
document.addEventListener("click", function (evt) {
if (!evt.target.closest(".multi-select-container")) {
document.querySelectorAll(".multi-select-dropdown").forEach(function (d) {
d.classList.add("hidden");
});
}
});

View File

@@ -0,0 +1,84 @@
// localtime.js - Converts UTC <time> elements to the user's local timezone.
//
// Usage: <time datetime="2026-01-14T10:30:00Z" data-localtime="datetime">fallback</time>
//
// Supported data-localtime values:
// "date" → "Mon 2 Jan 2026"
// "time" → "3:04 PM"
// "datetime" → "Mon 2 Jan 2026 at 3:04 PM"
// "short" → "Mon 2 Jan 3:04 PM"
// "histdate" → "2 Jan 2006 15:04"
(function () {
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function formatTime12(d) {
let h = d.getHours();
const m = pad(d.getMinutes());
const ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12 || 12;
return h + ':' + m + ' ' + ampm;
}
function formatDate(d) {
return SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
SHORT_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
}
function formatLocalTime(el) {
const iso = el.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d.getTime())) return;
const fmt = el.getAttribute('data-localtime');
let text;
switch (fmt) {
case 'date':
text = formatDate(d);
break;
case 'time':
text = formatTime12(d);
break;
case 'datetime':
text = formatDate(d) + ' at ' + formatTime12(d);
break;
case 'short':
text = SHORT_DAYS[d.getDay()] + ' ' + d.getDate() + ' ' +
SHORT_MONTHS[d.getMonth()] + ' ' + formatTime12(d);
break;
case 'histdate':
text = d.getDate() + ' ' + SHORT_MONTHS[d.getMonth()] + ' ' +
d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
break;
default:
text = formatDate(d) + ' at ' + formatTime12(d);
}
el.textContent = text;
// Add timezone tooltip so users know the displayed time is in their local timezone
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
el.title = 'Displayed in your local timezone (' + tz + ')';
}
function processAll(root) {
const els = (root || document).querySelectorAll('time[data-localtime]');
els.forEach(formatLocalTime);
}
// Process on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { processAll(); });
} else {
processAll();
}
// Re-process after HTMX swaps
document.addEventListener('htmx:afterSettle', function (evt) {
processAll(evt.detail.elt);
});
})();

View File

@@ -25,6 +25,19 @@ function paginateData(
this.submit();
},
sortByColumn(field) {
if (this.orderBy === field) {
// Toggle order if same column
this.order = this.order === "ASC" ? "DESC" : "ASC";
} else {
// New column, default to DESC
this.orderBy = field;
this.order = "DESC";
}
this.page = 1; // Reset to first page when sorting
this.submit();
},
setPerPage(n) {
this.perPage = n;
this.page = 1; // Reset to first page when changing per page

View File

@@ -0,0 +1,50 @@
// Generic tab navigation handler
// Manages active tab styling after HTMX content swaps.
//
// Usage: Add data-tab-nav="<content-target-id>" to your <nav> element.
// Tab links inside the nav should have href attributes ending with the section name.
//
// Example:
// <nav data-tab-nav="admin-content">
// <a href="/admin/users" hx-post="/admin/users" hx-target="#admin-content">Users</a>
// </nav>
// <main id="admin-content">...</main>
(function () {
var activeClasses = ["border-blue", "text-blue", "font-semibold"];
var inactiveClasses = [
"border-transparent",
"text-subtext0",
"hover:text-text",
"hover:border-surface2",
];
function updateActiveTab(targetId) {
var nav = document.querySelector('[data-tab-nav="' + targetId + '"]');
if (!nav) return;
var path = window.location.pathname;
var section = path.split("/").pop() || "";
nav.querySelectorAll("a").forEach(function (link) {
var href = link.getAttribute("href");
var isActive = href && href.endsWith("/" + section);
activeClasses.forEach(function (cls) {
isActive ? link.classList.add(cls) : link.classList.remove(cls);
});
inactiveClasses.forEach(function (cls) {
isActive ? link.classList.remove(cls) : link.classList.add(cls);
});
});
}
document.addEventListener("DOMContentLoaded", function () {
document.body.addEventListener("htmx:afterSwap", function (event) {
var targetId = event.detail.target.id;
if (targetId) {
updateActiveTab(targetId);
}
});
});
})();

View File

@@ -0,0 +1,795 @@
.flatpickr-calendar {
background: transparent;
opacity: 0;
display: none;
text-align: center;
visibility: hidden;
padding: 0;
-webkit-animation: none;
animation: none;
direction: ltr;
border: 0;
font-size: 14px;
line-height: 24px;
border-radius: 5px;
position: absolute;
width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-ms-touch-action: manipulation;
touch-action: manipulation;
background: #3f4458;
-webkit-box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
}
.flatpickr-calendar.open,
.flatpickr-calendar.inline {
opacity: 1;
max-height: 640px;
visibility: visible;
}
.flatpickr-calendar.open {
display: inline-block;
z-index: 99999;
}
.flatpickr-calendar.animate.open {
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.flatpickr-calendar.inline {
display: block;
position: relative;
top: 2px;
}
.flatpickr-calendar.static {
position: absolute;
top: calc(100% + 2px);
}
.flatpickr-calendar.static.open {
z-index: 999;
display: block;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
-webkit-box-shadow: none !important;
box-shadow: none !important;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
}
.flatpickr-calendar .hasWeeks .dayContainer,
.flatpickr-calendar .hasTime .dayContainer {
border-bottom: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.flatpickr-calendar .hasWeeks .dayContainer {
border-left: 0;
}
.flatpickr-calendar.hasTime .flatpickr-time {
height: 40px;
border-top: 1px solid #20222c;
}
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
height: auto;
}
.flatpickr-calendar:before,
.flatpickr-calendar:after {
position: absolute;
display: block;
pointer-events: none;
border: solid transparent;
content: '';
height: 0;
width: 0;
left: 22px;
}
.flatpickr-calendar.rightMost:before,
.flatpickr-calendar.arrowRight:before,
.flatpickr-calendar.rightMost:after,
.flatpickr-calendar.arrowRight:after {
left: auto;
right: 22px;
}
.flatpickr-calendar.arrowCenter:before,
.flatpickr-calendar.arrowCenter:after {
left: 50%;
right: 50%;
}
.flatpickr-calendar:before {
border-width: 5px;
margin: 0 -5px;
}
.flatpickr-calendar:after {
border-width: 4px;
margin: 0 -4px;
}
.flatpickr-calendar.arrowTop:before,
.flatpickr-calendar.arrowTop:after {
bottom: 100%;
}
.flatpickr-calendar.arrowTop:before {
border-bottom-color: #20222c;
}
.flatpickr-calendar.arrowTop:after {
border-bottom-color: #3f4458;
}
.flatpickr-calendar.arrowBottom:before,
.flatpickr-calendar.arrowBottom:after {
top: 100%;
}
.flatpickr-calendar.arrowBottom:before {
border-top-color: #20222c;
}
.flatpickr-calendar.arrowBottom:after {
border-top-color: #3f4458;
}
.flatpickr-calendar:focus {
outline: 0;
}
.flatpickr-wrapper {
position: relative;
display: inline-block;
}
.flatpickr-months {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-months .flatpickr-month {
background: #3f4458;
color: #fff;
fill: #fff;
height: 34px;
line-height: 1;
text-align: center;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
overflow: hidden;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-decoration: none;
cursor: pointer;
position: absolute;
top: 0;
height: 34px;
padding: 10px;
z-index: 3;
color: #fff;
fill: #fff;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
display: none;
}
.flatpickr-months .flatpickr-prev-month i,
.flatpickr-months .flatpickr-next-month i {
position: relative;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
/*
/*rtl:begin:ignore*/
/*
*/
left: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
/*
/*rtl:begin:ignore*/
/*
*/
right: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: #eee;
}
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
fill: #f64747;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
width: 14px;
height: 14px;
}
.flatpickr-months .flatpickr-prev-month svg path,
.flatpickr-months .flatpickr-next-month svg path {
-webkit-transition: fill 0.1s;
transition: fill 0.1s;
fill: inherit;
}
.numInputWrapper {
position: relative;
height: auto;
}
.numInputWrapper input,
.numInputWrapper span {
display: inline-block;
}
.numInputWrapper input {
width: 100%;
}
.numInputWrapper input::-ms-clear {
display: none;
}
.numInputWrapper input::-webkit-outer-spin-button,
.numInputWrapper input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.numInputWrapper span {
position: absolute;
right: 0;
width: 14px;
padding: 0 4px 0 2px;
height: 50%;
line-height: 50%;
opacity: 0;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.15);
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.numInputWrapper span:hover {
background: rgba(192,187,167,0.1);
}
.numInputWrapper span:active {
background: rgba(192,187,167,0.2);
}
.numInputWrapper span:after {
display: block;
content: "";
position: absolute;
}
.numInputWrapper span.arrowUp {
top: 0;
border-bottom: 0;
}
.numInputWrapper span.arrowUp:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid rgba(255,255,255,0.6);
top: 26%;
}
.numInputWrapper span.arrowDown {
top: 50%;
}
.numInputWrapper span.arrowDown:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid rgba(255,255,255,0.6);
top: 40%;
}
.numInputWrapper span svg {
width: inherit;
height: auto;
}
.numInputWrapper span svg path {
fill: rgba(255,255,255,0.5);
}
.numInputWrapper:hover {
background: rgba(192,187,167,0.05);
}
.numInputWrapper:hover span {
opacity: 1;
}
.flatpickr-current-month {
font-size: 135%;
line-height: inherit;
font-weight: 300;
color: inherit;
position: absolute;
width: 75%;
left: 12.5%;
padding: 7.48px 0 0 0;
line-height: 1;
height: 34px;
display: inline-block;
text-align: center;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
}
.flatpickr-current-month span.cur-month {
font-family: inherit;
font-weight: 700;
color: inherit;
display: inline-block;
margin-left: 0.5ch;
padding: 0;
}
.flatpickr-current-month span.cur-month:hover {
background: rgba(192,187,167,0.05);
}
.flatpickr-current-month .numInputWrapper {
width: 6ch;
width: 7ch\0;
display: inline-block;
}
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
border-bottom-color: #fff;
}
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
border-top-color: #fff;
}
.flatpickr-current-month input.cur-year {
background: transparent;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: inherit;
cursor: text;
padding: 0 0 0 0.5ch;
margin: 0;
display: inline-block;
font-size: inherit;
font-family: inherit;
font-weight: 300;
line-height: inherit;
height: auto;
border: 0;
border-radius: 0;
vertical-align: initial;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-current-month input.cur-year:focus {
outline: 0;
}
.flatpickr-current-month input.cur-year[disabled],
.flatpickr-current-month input.cur-year[disabled]:hover {
font-size: 100%;
color: rgba(255,255,255,0.5);
background: transparent;
pointer-events: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
appearance: menulist;
background: #3f4458;
border: none;
border-radius: 0;
box-sizing: border-box;
color: inherit;
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 300;
height: auto;
line-height: inherit;
margin: -1px 0 0 0;
outline: none;
padding: 0 0 0 0.5ch;
position: relative;
vertical-align: initial;
-webkit-box-sizing: border-box;
-webkit-appearance: menulist;
-moz-appearance: menulist;
width: auto;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
.flatpickr-current-month .flatpickr-monthDropdown-months:active {
outline: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
background: rgba(192,187,167,0.05);
}
.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
background-color: #3f4458;
outline: none;
padding: 0;
}
.flatpickr-weekdays {
background: transparent;
text-align: center;
overflow: hidden;
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
height: 28px;
}
.flatpickr-weekdays .flatpickr-weekdaycontainer {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
span.flatpickr-weekday {
cursor: default;
font-size: 90%;
background: #3f4458;
color: #fff;
line-height: 1;
margin: 0;
text-align: center;
display: block;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
font-weight: bolder;
}
.dayContainer,
.flatpickr-weeks {
padding: 1px 0 0 0;
}
.flatpickr-days {
position: relative;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: start;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
width: 307.875px;
}
.flatpickr-days:focus {
outline: 0;
}
.dayContainer {
padding: 0;
outline: 0;
text-align: left;
width: 307.875px;
min-width: 307.875px;
max-width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
display: inline-block;
display: -ms-flexbox;
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: wrap;
flex-wrap: wrap;
-ms-flex-wrap: wrap;
-ms-flex-pack: justify;
-webkit-justify-content: space-around;
justify-content: space-around;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
opacity: 1;
}
.dayContainer + .dayContainer {
-webkit-box-shadow: -1px 0 0 #20222c;
box-shadow: -1px 0 0 #20222c;
}
.flatpickr-day {
background: none;
border: 1px solid transparent;
border-radius: 150px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: rgba(255,255,255,0.95);
cursor: pointer;
font-weight: 400;
width: 14.2857143%;
-webkit-flex-basis: 14.2857143%;
-ms-flex-preferred-size: 14.2857143%;
flex-basis: 14.2857143%;
max-width: 39px;
height: 39px;
line-height: 39px;
margin: 0;
display: inline-block;
position: relative;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
}
.flatpickr-day.inRange,
.flatpickr-day.prevMonthDay.inRange,
.flatpickr-day.nextMonthDay.inRange,
.flatpickr-day.today.inRange,
.flatpickr-day.prevMonthDay.today.inRange,
.flatpickr-day.nextMonthDay.today.inRange,
.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
cursor: pointer;
outline: 0;
background: #646c8c;
border-color: #646c8c;
}
.flatpickr-day.today {
border-color: #eee;
}
.flatpickr-day.today:hover,
.flatpickr-day.today:focus {
border-color: #eee;
background: #eee;
color: #3f4458;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange,
.flatpickr-day.selected:focus,
.flatpickr-day.startRange:focus,
.flatpickr-day.endRange:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.startRange.prevMonthDay,
.flatpickr-day.endRange.prevMonthDay,
.flatpickr-day.selected.nextMonthDay,
.flatpickr-day.startRange.nextMonthDay,
.flatpickr-day.endRange.nextMonthDay {
background: #80cbc4;
-webkit-box-shadow: none;
box-shadow: none;
color: #fff;
border-color: #80cbc4;
}
.flatpickr-day.selected.startRange,
.flatpickr-day.startRange.startRange,
.flatpickr-day.endRange.startRange {
border-radius: 50px 0 0 50px;
}
.flatpickr-day.selected.endRange,
.flatpickr-day.startRange.endRange,
.flatpickr-day.endRange.endRange {
border-radius: 0 50px 50px 0;
}
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
-webkit-box-shadow: -10px 0 0 #80cbc4;
box-shadow: -10px 0 0 #80cbc4;
}
.flatpickr-day.selected.startRange.endRange,
.flatpickr-day.startRange.startRange.endRange,
.flatpickr-day.endRange.startRange.endRange {
border-radius: 50px;
}
.flatpickr-day.inRange {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover,
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay,
.flatpickr-day.notAllowed,
.flatpickr-day.notAllowed.prevMonthDay,
.flatpickr-day.notAllowed.nextMonthDay {
color: rgba(255,255,255,0.3);
background: transparent;
border-color: transparent;
cursor: default;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
cursor: not-allowed;
color: rgba(255,255,255,0.1);
}
.flatpickr-day.week.selected {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
}
.flatpickr-day.hidden {
visibility: hidden;
}
.rangeMode .flatpickr-day {
margin-top: 1px;
}
.flatpickr-weekwrapper {
float: left;
}
.flatpickr-weekwrapper .flatpickr-weeks {
padding: 0 12px;
-webkit-box-shadow: 1px 0 0 #20222c;
box-shadow: 1px 0 0 #20222c;
}
.flatpickr-weekwrapper .flatpickr-weekday {
float: none;
width: 100%;
line-height: 28px;
}
.flatpickr-weekwrapper span.flatpickr-day,
.flatpickr-weekwrapper span.flatpickr-day:hover {
display: block;
width: 100%;
max-width: none;
color: rgba(255,255,255,0.3);
background: transparent;
cursor: default;
border: none;
}
.flatpickr-innerContainer {
display: block;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
}
.flatpickr-rContainer {
display: inline-block;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.flatpickr-time {
text-align: center;
outline: 0;
display: block;
height: 0;
line-height: 40px;
max-height: 40px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-time:after {
content: "";
display: table;
clear: both;
}
.flatpickr-time .numInputWrapper {
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
width: 40%;
height: 40px;
float: left;
}
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: rgba(255,255,255,0.95);
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: rgba(255,255,255,0.95);
}
.flatpickr-time.hasSeconds .numInputWrapper {
width: 26%;
}
.flatpickr-time.time24hr .numInputWrapper {
width: 49%;
}
.flatpickr-time input {
background: transparent;
-webkit-box-shadow: none;
box-shadow: none;
border: 0;
border-radius: 0;
text-align: center;
margin: 0;
padding: 0;
height: inherit;
line-height: inherit;
color: rgba(255,255,255,0.95);
font-size: 14px;
position: relative;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-time input.flatpickr-hour {
font-weight: bold;
}
.flatpickr-time input.flatpickr-minute,
.flatpickr-time input.flatpickr-second {
font-weight: 400;
}
.flatpickr-time input:focus {
outline: 0;
border: 0;
}
.flatpickr-time .flatpickr-time-separator,
.flatpickr-time .flatpickr-am-pm {
height: inherit;
float: left;
line-height: inherit;
color: rgba(255,255,255,0.95);
font-weight: bold;
width: 2%;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-align-self: center;
-ms-flex-item-align: center;
align-self: center;
}
.flatpickr-time .flatpickr-am-pm {
outline: 0;
width: 18%;
cursor: pointer;
text-align: center;
font-weight: 400;
}
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: #6a7395;
}
.flatpickr-input[readonly] {
cursor: pointer;
}
@-webkit-keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,27 +11,27 @@ import (
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
adminview "git.haelnorr.com/h/oslstats/internal/view/adminview"
"git.haelnorr.com/h/timefmt"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// AdminAuditLogsPage renders the full admin dashboard page with audit logs section
func AdminAuditLogsPage(s *hws.Server, conn *bun.DB) http.Handler {
// AdminAuditLogsPage renders the full admin dashboard page with audit logs section (GET request)
func AdminAuditLogsPage(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 logs *db.List[db.AuditLog]
var users []*db.User
var actions []string
var resourceTypes []string
if ok := db.WithReadTx(s, w, r, conn, 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
// Get page options from query
pageOpts := pageOptsFromQuery(s, w, r)
if pageOpts == nil {
return false, nil
}
// Get filters from query
filters, ok := getAuditFiltersFromQuery(s, w, r)
if !ok {
@@ -72,26 +72,28 @@ func AdminAuditLogsPage(s *hws.Server, conn *bun.DB) http.Handler {
})
}
// AdminAuditLogsList shows audit logs (HTMX content replacement - full section with filters)
func AdminAuditLogsList(s *hws.Server, conn *bun.DB) http.Handler {
// AdminAuditLogsList shows the full audit logs list with filters (POST request for HTMX)
func AdminAuditLogsList(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 logs *db.List[db.AuditLog]
var users []*db.User
var actions []string
var resourceTypes []string
if ok := db.WithReadTx(s, w, r, conn, 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
// Get page options from form
pageOpts := pageOptsFromForm(s, w, r)
if pageOpts == nil {
// Get filters from form
filters, ok := getAuditFiltersFromForm(s, w, r)
if !ok {
return false, nil
}
// No filters for initial section load
filters := db.NewAuditLogFilter()
// Get audit logs
logs, err = db.GetAuditLogs(ctx, tx, pageOpts, filters)
if err != nil {
@@ -126,20 +128,19 @@ func AdminAuditLogsList(s *hws.Server, conn *bun.DB) http.Handler {
})
}
// AdminAuditLogsFilter handles filter requests and returns only the results table
func AdminAuditLogsFilter(s *hws.Server, conn *bun.DB) http.Handler {
// AdminAuditLogsFilter returns only the results container (table + pagination) for HTMX updates
func AdminAuditLogsFilter(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 logs *db.List[db.AuditLog]
if ok := db.WithReadTx(s, w, r, conn, 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
// Get page options from form
pageOpts := pageOptsFromForm(s, w, r)
if pageOpts == nil {
return false, nil
}
// Get filters from form
filters, ok := getAuditFiltersFromForm(s, w, r)
if !ok {
@@ -157,12 +158,13 @@ func AdminAuditLogsFilter(s *hws.Server, conn *bun.DB) http.Handler {
return
}
// Return only the results container, not the full page with filters
renderSafely(adminview.AuditLogsResults(logs), s, r, w)
})
}
// AdminAuditLogDetail shows details for a single audit log entry
func AdminAuditLogDetail(s *hws.Server, conn *bun.DB) http.Handler {
func AdminAuditLogDetail(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get ID from path
idStr := r.PathValue("id")
@@ -179,16 +181,16 @@ func AdminAuditLogDetail(s *hws.Server, conn *bun.DB) http.Handler {
var log *db.AuditLog
if ok := db.WithReadTx(s, w, r, conn, 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
log, err = db.GetAuditLogByID(ctx, tx, id)
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")
}
if log == nil {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return true, nil
}); !ok {
return
@@ -201,7 +203,8 @@ func AdminAuditLogDetail(s *hws.Server, conn *bun.DB) http.Handler {
// getAuditFiltersFromQuery extracts audit log filters from query string
func getAuditFiltersFromQuery(s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) {
g := validation.NewQueryGetter(r)
return buildAuditFilters(g, s, w, r)
filters, ok := buildAuditFilters(g, s, w, r)
return filters, ok
}
// getAuditFiltersFromForm extracts audit log filters from form data
@@ -217,57 +220,38 @@ func getAuditFiltersFromForm(s *hws.Server, w http.ResponseWriter, r *http.Reque
func buildAuditFilters(g validation.Getter, s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) {
filters := db.NewAuditLogFilter()
// User ID filter (optional)
userID := g.Int("user_id").Optional().Min(1).Value
userIDs := g.IntList("user_id").Values()
actions := g.StringList("action").Values()
resourceTypes := g.StringList("resource_type").Values()
results := g.StringList("result").Values()
format := timefmt.NewBuilder().DayNumeric2().Slash().
MonthNumeric2().Slash().Year4().Build()
startDate := g.Time("start_date", format).Optional().Value
endDate := g.Time("end_date", format).Optional().Value
// Action filter (optional)
action := g.String("action").TrimSpace().Optional().Value
// Resource Type filter (optional)
resourceType := g.String("resource_type").TrimSpace().Optional().Value
// Result filter (optional)
result := g.String("result").TrimSpace().Optional().AllowedValues([]string{"success", "denied", "error"}).Value
// Date range filter (optional)
startDateStr := g.String("start_date").TrimSpace().Optional().Value
endDateStr := g.String("end_date").TrimSpace().Optional().Value
// Validate
if !g.ValidateAndError(s, w, r) {
return nil, false
}
// Apply filters
if userID > 0 {
filters.UserID(userID)
if len(userIDs) > 0 {
filters.UserIDs(userIDs)
}
if len(actions) > 0 {
filters.Actions(actions)
}
if len(resourceTypes) > 0 {
filters.ResourceTypes(resourceTypes)
}
if len(results) > 0 {
filters.Results(results)
}
if action != "" {
filters.Action(action)
if !startDate.IsZero() {
filters.DateRange(startDate.Unix(), 0)
}
if resourceType != "" {
filters.ResourceType(resourceType)
}
if result != "" {
filters.Result(result)
}
// Parse and apply date range
if startDateStr != "" {
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
filters.DateRange(startDate.Unix(), 0)
}
}
if endDateStr != "" {
if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil {
// Set to end of day
endOfDay := endDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
filters.DateRange(0, endOfDay.Unix())
}
if !endDate.IsZero() {
endOfDay := endDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
filters.DateRange(0, endOfDay.Unix())
}
return filters, true

View File

@@ -12,10 +12,10 @@ import (
)
// AdminDashboard renders the full admin dashboard page (defaults to users section)
func AdminDashboard(s *hws.Server, conn *bun.DB) http.Handler {
func AdminDashboard(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var users *db.List[db.User]
if ok := db.WithReadTx(s, w, r, conn, 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
users, err = db.GetUsersWithRoles(ctx, tx, nil)
if err != nil {

View File

@@ -1,25 +0,0 @@
package handlers
import (
"net/http"
"git.haelnorr.com/h/golib/hws"
adminview "git.haelnorr.com/h/oslstats/internal/view/adminview"
"github.com/uptrace/bun"
)
// AdminPermissionsPage renders the full admin dashboard page with permissions section
func AdminPermissionsPage(s *hws.Server, conn *bun.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Load permissions from database
renderSafely(adminview.PermissionsPage(), s, r, w)
})
}
// AdminPermissionsList shows all permissions (HTMX content replacement)
func AdminPermissionsList(s *hws.Server, conn *bun.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Load permissions from database
renderSafely(adminview.PermissionsList(), s, r, w)
})
}

View File

@@ -0,0 +1,79 @@
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/rbac"
"git.haelnorr.com/h/oslstats/internal/roles"
"git.haelnorr.com/h/oslstats/internal/throw"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// AdminPreviewRoleStart starts preview mode for a specific role
func AdminPreviewRoleStart(s *hws.Server, conn *db.DB, ssl bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get role ID from URL
roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
throw.BadRequest(s, w, r, "Invalid role ID", err)
return
}
// Verify role exists and is not admin
var role *db.Role
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
role, err = db.GetRoleByID(ctx, tx, roleID)
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")
}
// Cannot preview admin role
if role.Name == roles.Admin {
throw.BadRequest(s, w, r, "Cannot preview admin role", nil)
return false, nil
}
return true, nil
}); !ok {
return
}
// Set preview role cookie
rbac.SetPreviewRoleCookie(w, roleID, ssl)
// Redirect to home page
http.Redirect(w, r, "/", http.StatusSeeOther)
})
}
// AdminPreviewRoleStop stops preview mode and returns to normal view
func AdminPreviewRoleStop(s *hws.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Clear preview role cookie
rbac.ClearPreviewRoleCookie(w)
// Check if we should stay on current page or redirect to admin
stay := r.URL.Query().Get("stay")
if stay == "true" {
// Get referer to redirect back to current page
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
} else {
// Redirect to admin roles page
http.Redirect(w, r, "/admin/roles", http.StatusSeeOther)
}
})
}

View File

@@ -1,25 +1,341 @@
package handlers
import (
"context"
"net/http"
"sort"
"strconv"
"time"
"git.haelnorr.com/h/golib/hws"
"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/validation"
adminview "git.haelnorr.com/h/oslstats/internal/view/adminview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// AdminRolesPage renders the full admin dashboard page with roles section
func AdminRolesPage(s *hws.Server, conn *bun.DB) http.Handler {
// AdminRoles renders the full admin dashboard page with roles section
func AdminRoles(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Load roles from database
renderSafely(adminview.RolesPage(), s, r, w)
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
var rolesList *db.List[db.Role]
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
rolesList, err = db.GetRoles(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.GetRoles")
}
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(adminview.RolesPage(rolesList), s, r, w)
} else {
renderSafely(adminview.RolesList(rolesList), s, r, w)
}
})
}
// AdminRolesList shows all roles (HTMX content replacement)
func AdminRolesList(s *hws.Server, conn *bun.DB) http.Handler {
// AdminRoleCreateForm shows the create role form modal
func AdminRoleCreateForm(s *hws.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Load roles from database
renderSafely(adminview.RolesList(), s, r, w)
renderSafely(adminview.RoleCreateForm(), s, r, w)
})
}
// AdminRoleCreate creates a new role
func AdminRoleCreate(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
}
name := getter.String("name").Required().Value
displayName := getter.String("display_name").Required().Value
description := getter.String("description").Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
var rolesList *db.List[db.Role]
var newRole *db.Role
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
newRole = &db.Role{
Name: roles.Role(name),
DisplayName: displayName,
Description: description,
IsSystem: false,
CreatedAt: time.Now().Unix(),
}
err := db.CreateRole(ctx, tx, newRole, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.CreateRole")
}
rolesList, err = db.GetRoles(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.GetRoles")
}
return true, nil
}); !ok {
return
}
renderSafely(adminview.RolesList(rolesList), s, r, w)
})
}
// AdminRoleManage shows the role management modal with details and actions
func AdminRoleManage(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
respond.BadRequest(w, err)
return
}
var role *db.Role
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID")
}
return true, nil
}); !ok {
return
}
renderSafely(adminview.RoleManageModal(role), s, r, w)
})
}
// AdminRoleDeleteConfirm shows the delete confirmation dialog
func AdminRoleDeleteConfirm(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var role *db.Role
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID")
}
return true, nil
}); !ok {
return
}
renderSafely(adminview.ConfirmDeleteRole(roleID, role.DisplayName), s, r, w)
})
}
// AdminRoleDelete deletes a role
func AdminRoleDelete(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
respond.BadRequest(w, err)
return
}
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
var rolesList *db.List[db.Role]
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// First check if role exists and get its details
role, err := db.GetRoleByID(ctx, tx, roleID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID")
}
// Check if it's a system role
if role.IsSystem {
return false, errors.New("cannot delete system roles")
}
// Delete the role with audit logging
err = db.DeleteRole(ctx, tx, roleID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.DeleteRole")
}
// Reload roles
rolesList, err = db.GetRoles(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.GetRoles")
}
return true, nil
}); !ok {
return
}
renderSafely(adminview.RolesList(rolesList), s, r, w)
})
}
// AdminRolePermissionsModal shows the permissions management modal for a role
func AdminRolePermissionsModal(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
respond.BadRequest(w, err)
return
}
var role *db.Role
var allPermissions []*db.Permission
var groupedPerms []adminview.PermissionsByResource
var rolePermIDs map[int]bool
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// Load role with permissions
var err error
role, err = db.GetRoleByID(ctx, tx, roleID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID")
}
// Load all permissions
allPermissions, err = db.ListAllPermissions(ctx, tx)
if err != nil {
return false, errors.Wrap(err, "db.ListAllPermissions")
}
return true, nil
}); !ok {
return
}
// Group permissions by resource
permsByResource := make(map[string][]*db.Permission)
for _, perm := range allPermissions {
permsByResource[perm.Resource] = append(permsByResource[perm.Resource], perm)
}
// Convert to sorted slice
for resource, perms := range permsByResource {
groupedPerms = append(groupedPerms, adminview.PermissionsByResource{
Resource: resource,
Permissions: perms,
})
}
sort.Slice(groupedPerms, func(i, j int) bool {
return groupedPerms[i].Resource < groupedPerms[j].Resource
})
// Create map of current role permissions for checkbox state
rolePermIDs = make(map[int]bool)
for _, perm := range role.Permissions {
rolePermIDs[perm.ID] = true
}
renderSafely(adminview.RolePermissionsModal(role, groupedPerms, rolePermIDs), s, r, w)
})
}
// AdminRolePermissionsUpdate updates the permissions for a role
func AdminRolePermissionsUpdate(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleIDStr := r.PathValue("id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
respond.BadRequest(w, err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
// Get selected permission IDs from form
permissionIDs := getter.IntList("permission_ids").Values()
if !getter.ValidateAndNotify(s, w, r) {
return
}
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
var rolesList *db.List[db.Role]
if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
role, err := db.GetRoleByID(ctx, tx, roleID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetRoleByID")
}
err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "role.UpdatePermissions")
}
// Reload roles
rolesList, err = db.GetRoles(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.GetRoles")
}
return true, nil
}); !ok {
return
}
renderSafely(adminview.RolesList(rolesList), s, r, w)
})
}

View File

@@ -12,31 +12,17 @@ import (
)
// AdminUsersPage renders the full admin dashboard page with users section
func AdminUsersPage(s *hws.Server, conn *bun.DB) http.Handler {
func AdminUsersPage(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var users *db.List[db.User]
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
users, err = db.GetUsersWithRoles(ctx, tx, nil)
if err != nil {
return false, errors.Wrap(err, "db.GetUsersWithRoles")
}
return true, nil
}); !ok {
pageOpts, ok := db.GetPageOpts(s, w, r)
if !ok {
return
}
renderSafely(adminview.DashboardPage(users), s, r, w)
})
}
// AdminUsersList shows all users (HTMX content replacement)
func AdminUsersList(s *hws.Server, conn *bun.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var users *db.List[db.User]
if ok := db.WithReadTx(s, w, r, conn, 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
// Get users with their roles
users, err = db.GetUsersWithRoles(ctx, tx, nil)
users, err = db.GetUsersWithRoles(ctx, tx, pageOpts)
if err != nil {
return false, errors.Wrap(err, "db.GetUsersWithRoles")
}
@@ -44,6 +30,10 @@ func AdminUsersList(s *hws.Server, conn *bun.DB) http.Handler {
}); !ok {
return
}
renderSafely(adminview.UserList(users), s, r, w)
if r.Method == "GET" {
renderSafely(adminview.DashboardPage(users), s, r, w)
} else {
renderSafely(adminview.UserList(users), s, r, w)
}
})
}

View File

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

View File

@@ -22,7 +22,7 @@ import (
func Callback(
s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB,
conn *db.DB,
cfg *config.Config,
store *store.Store,
discordAPI *discord.APIClient,
@@ -70,7 +70,7 @@ func Callback(
switch data {
case "login":
var redirect func()
if ok := db.WithWriteTx(s, w, r, conn, 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) {
redirect, err = login(ctx, auth, tx, cfg, w, r, code, store, discordAPI)
if err != nil {
throw.InternalServiceError(s, w, r, "OAuth login failed", err)
@@ -158,7 +158,7 @@ func login(
}
user, err := db.GetUserByDiscordID(ctx, tx, discorduser.ID)
if err != nil {
if err != nil && !db.IsBadRequest(err) {
return nil, errors.Wrap(err, "db.GetUserByDiscordID")
}
var redirect string

View File

@@ -0,0 +1,501 @@
package handlers
import (
"context"
"net/http"
"strconv"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/contexts"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"git.haelnorr.com/h/timefmt"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
// result display, and team rosters
func FixtureDetailPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
activeTab := r.URL.Query().Get("tab")
if activeTab == "" {
activeTab = "overview"
}
var fixture *db.Fixture
var currentSchedule *db.FixtureSchedule
var history []*db.FixtureSchedule
var canSchedule bool
var userTeamID int
var result *db.FixtureResult
var rosters map[string][]*db.PlayerWithPlayStatus
var nominatedFreeAgents []*db.FixtureFreeAgent
var availableFreeAgents []*db.SeasonLeagueFreeAgent
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
currentSchedule, err = db.GetCurrentFixtureSchedule(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetCurrentFixtureSchedule")
}
history, err = db.GetFixtureScheduleHistory(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureScheduleHistory")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err = fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
// Fetch fixture result if it exists
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
// Fetch team rosters with play status
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
}
// Fetch free agent nominations for this fixture
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if canSchedule || canManage {
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureDetailPage(
fixture, currentSchedule, history, canSchedule, userTeamID,
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
), s, r, w)
})
}
// ProposeSchedule handles POST /fixtures/{fixture_id}/schedule
func ProposeSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
DayNumeric2().T().Hour24().Colon().Minute().Build()
aest, _ := time.LoadLocation("Australia/Sydney")
// scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to propose a schedule", nil)
return false, nil
}
_, err = db.ProposeFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Propose", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.ProposeFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// AcceptSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/accept
func AcceptSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to accept a schedule", nil)
return false, nil
}
err = db.AcceptFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Accept", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.AcceptFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Schedule Accepted", "The fixture time has been confirmed.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// RejectSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/reject
func RejectSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, _, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to reject a schedule", nil)
return false, nil
}
err = db.RejectFixtureSchedule(ctx, tx, scheduleID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Reject", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.RejectFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Schedule Rejected", "The proposed time has been rejected.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// PostponeSchedule handles POST /fixtures/{fixture_id}/schedule/postpone
func PostponeSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, _, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to postpone a fixture", nil)
return false, nil
}
err = db.PostponeFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Postpone", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.PostponeFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Fixture Postponed", "The fixture has been postponed.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// RescheduleFixture handles POST /fixtures/{fixture_id}/schedule/reschedule
func RescheduleFixture(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
DayNumeric2().T().Hour24().Colon().Minute().Build()
aest, _ := time.LoadLocation("Australia/Sydney")
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to reschedule a fixture", nil)
return false, nil
}
_, err = db.RescheduleFixtureSchedule(ctx, tx, fixtureID, userTeamID, scheduledTime, reason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Reschedule", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.RescheduleFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Fixture Rescheduled", "The new proposed time has been submitted.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// WithdrawSchedule handles POST /fixtures/{fixture_id}/schedule/{schedule_id}/withdraw
// Only the proposing team manager can withdraw their own pending proposal.
func WithdrawSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
scheduleID, err := strconv.Atoi(r.PathValue("schedule_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid schedule ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to withdraw a proposal", nil)
return false, nil
}
err = db.WithdrawFixtureSchedule(ctx, tx, scheduleID, userTeamID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Withdraw", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.WithdrawFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Proposal Withdrawn", "Your proposed time has been withdrawn.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// CancelSchedule handles POST /fixtures/{fixture_id}/schedule/cancel
// This is a moderator-only action that requires fixtures.manage permission.
func CancelSchedule(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.CancelFixtureSchedule(ctx, tx, fixtureID, reason, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Cancel", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.CancelFixtureSchedule")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Forfeit Declared", "The fixture has been declared a forfeit.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -0,0 +1,471 @@
package handlers
import (
"context"
"io"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
const maxUploadSize = 10 << 20 // 10 MB
// UploadMatchLogsPage renders the upload form for match log files
func UploadMatchLogsPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if result already exists
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
if existing != nil {
throw.BadRequest(s, w, r, "A result already exists for this fixture. Discard it first to re-upload.", nil)
return false, nil
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureUploadResultPage(fixture), s, r, w)
})
}
// UploadMatchLogs handles POST /fixtures/{fixture_id}/results/upload
// Parses 3 multipart files, validates, detects tampering, and stores results.
func UploadMatchLogs(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
// Parse multipart form
err = r.ParseMultipartForm(maxUploadSize)
if err != nil {
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files. Ensure files are under 10MB.", nil)
return
}
// Read the 3 period files
periodNames := []string{"period_1", "period_2", "period_3"}
logs := make([]*slapshotapi.MatchLog, 3)
for i, name := range periodNames {
file, _, err := r.FormFile(name)
if err != nil {
notify.Warn(s, w, r, "Missing File", "All 3 period files are required. Missing: "+name, nil)
return
}
defer func() { _ = file.Close() }()
data, err := io.ReadAll(file)
if err != nil {
notify.Warn(s, w, r, "Read Error", "Could not read file: "+name, nil)
return
}
log, err := slapshotapi.ParseMatchLog(data)
if err != nil {
notify.Warn(s, w, r, "Parse Error", "Could not parse "+name+": "+err.Error(), nil)
return
}
logs[i] = log
}
// Detect tampering
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
if err != nil {
notify.Warn(s, w, r, "Validation Error", "Tampering check failed: "+err.Error(), nil)
return
}
var result *db.FixtureResult
var unmappedPlayers []string
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if result already exists
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetFixtureResult")
}
if existing != nil {
notify.Warn(s, w, r, "Result Exists", "A result already exists for this fixture. Discard it first to re-upload.", nil)
return false, nil
}
// Collect all unique game_user_ids across all periods
gameUserIDSet := map[string]bool{}
for _, log := range logs {
for _, p := range log.Players {
gameUserIDSet[p.GameUserID] = true
}
}
gameUserIDs := make([]string, 0, len(gameUserIDSet))
for id := range gameUserIDSet {
gameUserIDs = append(gameUserIDs, id)
}
// Map game_user_ids to players
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
}
// Determine team orientation using all players from all periods
allPlayers := []slapshotapi.Player{}
// Use period 3 players for orientation (most complete)
allPlayers = append(allPlayers, logs[2].Players...)
fixtureHomeIsLogsHome, unmapped, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
if err != nil {
notify.Warn(s, w, r, "Orientation Error",
"Could not determine team orientation: "+err.Error()+". Please ensure players have registered Slapshot IDs.", nil)
return false, nil
}
unmappedPlayers = unmapped
// Use period 3 (final) data for the result
finalLog := logs[2]
// Determine winner in fixture terms
winner := finalLog.Winner
homeScore := finalLog.Score.Home
awayScore := finalLog.Score.Away
if !fixtureHomeIsLogsHome {
// Logs are reversed - swap
switch winner {
case "home":
winner = "away"
case "away":
winner = "home"
}
homeScore, awayScore = awayScore, homeScore
}
// Parse metadata
periodsEnabled := finalLog.PeriodsEnabled == "True"
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
user := db.CurrentUser(ctx)
// Build result
var tamperingReasonPtr *string
if tamperingDetected {
tamperingReasonPtr = &tamperingReason
}
result = &db.FixtureResult{
FixtureID: fixtureID,
Winner: winner,
HomeScore: homeScore,
AwayScore: awayScore,
MatchType: finalLog.Type,
Arena: finalLog.Arena,
EndReason: finalLog.EndReason,
PeriodsEnabled: periodsEnabled,
CustomMercyRule: customMercyRule,
MatchLength: matchLength,
UploadedByUserID: user.ID,
Finalized: false,
TamperingDetected: tamperingDetected,
TamperingReason: tamperingReasonPtr,
}
// Build player stats for all 3 periods
playerStats := []*db.FixtureResultPlayerStats{}
for periodIdx, log := range logs {
periodNum := periodIdx + 1
for _, p := range log.Players {
// Determine team in fixture terms
team := p.Team
if !fixtureHomeIsLogsHome {
if team == "home" {
team = "away"
} else {
team = "home"
}
}
// Look up player
var playerID *int
var teamID *int
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
playerID = &lookup.Player.ID
if !lookup.Unmapped {
teamID = &lookup.TeamID
}
}
stat := &db.FixtureResultPlayerStats{
PeriodNum: periodNum,
PlayerID: playerID,
PlayerGameUserID: p.GameUserID,
PlayerUsername: p.Username,
TeamID: teamID,
Team: team,
// Convert float stats to int
Goals: FloatToIntPtr(p.Stats.Goals),
Assists: FloatToIntPtr(p.Stats.Assists),
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
Saves: FloatToIntPtr(p.Stats.Saves),
Blocks: FloatToIntPtr(p.Stats.Blocks),
Shots: FloatToIntPtr(p.Stats.Shots),
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
Passes: FloatToIntPtr(p.Stats.Passes),
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
PostHits: FloatToIntPtr(p.Stats.PostHits),
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
Score: FloatToIntPtr(p.Stats.Score),
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
Wins: FloatToIntPtr(p.Stats.Wins),
Losses: FloatToIntPtr(p.Stats.Losses),
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
Ties: FloatToIntPtr(p.Stats.Ties),
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
}
playerStats = append(playerStats, stat)
}
}
// Check each player stat: if the player is a registered free agent, mark them
for _, ps := range playerStats {
if ps.PlayerID == nil {
continue
}
// Check if the player is a registered free agent
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
if err != nil {
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
}
if isFA {
ps.IsFreeAgent = true
}
}
// Insert result and stats
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.InsertFixtureResult")
}
return true, nil
}); !ok {
return
}
_ = unmappedPlayers // stored for review page redirect
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
})
}
// ReviewMatchResult handles GET /fixtures/{fixture_id}/results/review
func ReviewMatchResult(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
var fixture *db.Fixture
var result *db.FixtureResult
var unmappedPlayers []string
var faWarnings []seasonsview.FreeAgentWarning
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
fixture, err = db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
result, err = db.GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
}
if result == nil {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
// Get nominated free agents for this fixture
nominatedFAs, err := db.GetNominatedFreeAgents(ctx, tx, fixtureID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
}
// Map player ID to the side ("home"/"away") that nominated them
nominatedFASide := map[int]string{}
for _, nfa := range nominatedFAs {
if nfa.TeamID == fixture.HomeTeamID {
nominatedFASide[nfa.PlayerID] = "home"
} else {
nominatedFASide[nfa.PlayerID] = "away"
}
}
// Helper to resolve side to team name
teamNameForSide := func(side string) string {
if side == "home" {
return fixture.HomeTeam.Name
}
return fixture.AwayTeam.Name
}
// Build unmapped players and free agent warnings from stats
seen := map[int]bool{}
for _, ps := range result.PlayerStats {
if ps.PeriodNum != 3 {
continue
}
if ps.PlayerID == nil {
unmappedPlayers = append(unmappedPlayers,
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
} else if ps.IsFreeAgent && !seen[*ps.PlayerID] {
seen[*ps.PlayerID] = true
nominatedSide, wasNominated := nominatedFASide[*ps.PlayerID]
if !wasNominated {
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
Name: ps.PlayerUsername,
Reason: "not nominated for this fixture",
})
} else if nominatedSide != ps.Team {
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
Name: ps.PlayerUsername,
Reason: "nominated by " + teamNameForSide(nominatedSide) + ", but played for " + teamNameForSide(ps.Team),
})
}
}
}
return true, nil
}); !ok {
return
}
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), s, r, w)
})
}
// FinalizeMatchResult handles POST /fixtures/{fixture_id}/results/finalize
func FinalizeMatchResult(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.FinalizeFixtureResult(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.FinalizeFixtureResult")
}
return true, nil
}); !ok {
return
}
notify.SuccessWithDelay(s, w, r, "Result Finalized", "The match result has been finalized.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// DiscardMatchResult handles POST /fixtures/{fixture_id}/results/discard
func DiscardMatchResult(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
err := db.DeleteFixtureResult(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.DeleteFixtureResult")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Result Discarded", "The match result has been discarded. You can upload new logs.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -0,0 +1,187 @@
package handlers
import (
"context"
"fmt"
"math"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// PlayerLookupResult stores the resolved player info from a game_user_id lookup
type PlayerLookupResult struct {
Player *db.Player
TeamID int
Found bool
Unmapped bool // true if player not in system (potential free agent)
}
// MapGameUserIDsToPlayers creates a lookup map from game_user_id to resolved player info.
// It looks up players by their SlapID (which corresponds to game_user_id in match logs)
// and checks their team assignment in the given season/league.
func MapGameUserIDsToPlayers(
ctx context.Context,
tx bun.Tx,
gameUserIDs []string,
seasonID, leagueID int,
) (map[string]*PlayerLookupResult, error) {
result := make(map[string]*PlayerLookupResult, len(gameUserIDs))
// Initialize all as unmapped
for _, id := range gameUserIDs {
result[id] = &PlayerLookupResult{Unmapped: true}
}
if len(gameUserIDs) == 0 {
return result, nil
}
// Get all players that have a slap_id matching any of the game_user_ids
// game_user_id in logs is a string representation of the slapshot player ID (uint32)
players := []*db.Player{}
err := tx.NewSelect().
Model(&players).
Where("p.slap_id::text IN (?)", bun.In(gameUserIDs)).
Relation("User").
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect players")
}
// Build a map of slapID -> player
slapIDToPlayer := make(map[string]*db.Player, len(players))
playerIDs := make([]int, 0, len(players))
for _, p := range players {
if p.SlapID != nil {
key := slapIDStr(*p.SlapID)
slapIDToPlayer[key] = p
playerIDs = append(playerIDs, p.ID)
}
}
// Get team roster entries for these players in the given season/league
rosters := []*db.TeamRoster{}
if len(playerIDs) > 0 {
err = tx.NewSelect().
Model(&rosters).
Where("tr.season_id = ?", seasonID).
Where("tr.league_id = ?", leagueID).
Where("tr.player_id IN (?)", bun.In(playerIDs)).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect rosters")
}
}
// Build playerID -> teamID map
playerTeam := make(map[int]int, len(rosters))
for _, r := range rosters {
playerTeam[r.PlayerID] = r.TeamID
}
// Populate results
for _, id := range gameUserIDs {
player, found := slapIDToPlayer[id]
if !found {
continue // stays unmapped
}
teamID, onTeam := playerTeam[player.ID]
result[id] = &PlayerLookupResult{
Player: player,
TeamID: teamID,
Found: true,
Unmapped: !onTeam,
}
}
return result, nil
}
// DetermineTeamOrientation validates that logs match fixture's team assignment
// by cross-checking player game_user_ids against registered rosters.
//
// Returns:
// - fixtureHomeIsLogsHome: true if fixture's home team maps to "home" in logs
// - unmappedPlayers: list of game_user_ids that couldn't be resolved
// - error: if orientation cannot be determined
func DetermineTeamOrientation(
ctx context.Context,
tx bun.Tx,
fixture *db.Fixture,
allPlayers []slapshotapi.Player,
playerLookup map[string]*PlayerLookupResult,
) (bool, []string, error) {
if fixture == nil {
return false, nil, errors.New("fixture cannot be nil")
}
unmapped := []string{}
// Count how many fixture-home-team players are on "home" vs "away" in logs
homeTeamOnHome := 0 // fixture home team players that are "home" in logs
homeTeamOnAway := 0 // fixture home team players that are "away" in logs
awayTeamOnHome := 0 // fixture away team players that are "home" in logs
awayTeamOnAway := 0 // fixture away team players that are "away" in logs
for _, p := range allPlayers {
lookup, exists := playerLookup[p.GameUserID]
if !exists || lookup.Unmapped {
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
continue
}
logTeam := p.Team // "home" or "away" in the log
switch lookup.TeamID {
case fixture.HomeTeamID:
if logTeam == "home" {
homeTeamOnHome++
} else {
homeTeamOnAway++
}
case fixture.AwayTeamID:
if logTeam == "home" {
awayTeamOnHome++
} else {
awayTeamOnAway++
}
default:
// Player is on a team but not one of the fixture teams
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
}
}
totalMapped := homeTeamOnHome + homeTeamOnAway + awayTeamOnHome + awayTeamOnAway
if totalMapped == 0 {
return false, unmapped, errors.New("no mapped players found, cannot determine team orientation")
}
// Calculate orientation: how many agree with "home=home" vs "home=away"
matchOrientation := homeTeamOnHome + awayTeamOnAway // logs match fixture orientation
reverseOrientation := homeTeamOnAway + awayTeamOnHome // logs are reversed
if matchOrientation == reverseOrientation {
return false, unmapped, errors.New("cannot determine team orientation: equal evidence for both orientations")
}
fixtureHomeIsLogsHome := matchOrientation > reverseOrientation
return fixtureHomeIsLogsHome, unmapped, nil
}
// FloatToIntPtr converts a *float64 to *int by truncating the decimal.
// Returns nil if input is nil.
func FloatToIntPtr(f *float64) *int {
if f == nil {
return nil
}
v := int(math.Round(*f))
return &v
}
// slapIDStr converts a uint32 SlapID to a string for map lookups
func slapIDStr(id uint32) string {
return fmt.Sprintf("%d", id)
}

View File

@@ -0,0 +1,190 @@
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 sl *db.SeasonLeague
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.NewAuditFromRequest(r))
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")
}
sl, 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(sl.Season, sl.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) {
w.WriteHeader(http.StatusBadRequest)
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.NewAuditFromRequest(r))
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.NewAuditFromRequest(r))
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 {
var newValue *int
var oldValue int
if fixture.GameWeek != nil {
oldValue = *fixture.GameWeek
} else {
oldValue = 0
}
if gameWeek == 0 {
newValue = nil
} else {
newValue = &gameWeek
}
if gameWeek != oldValue {
fixture.GameWeek = newValue
updated = append(updated, fixture)
}
// fuck i hate pointers sometimes
}
if fixture.GameWeek != nil {
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

@@ -0,0 +1,356 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/contexts"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// FreeAgentsListPage renders the free agents tab of a season league page
func FreeAgentsListPage(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var freeAgents []*db.SeasonLeagueFreeAgent
var availablePlayers []*db.Player
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
season = sl.Season
league = sl.League
freeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
}
availablePlayers, err = db.GetPlayersNotOnTeam(ctx, tx, season.ID, league.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
}
// Filter out players already registered as free agents
faMap := make(map[int]bool, len(freeAgents))
for _, fa := range freeAgents {
faMap[fa.PlayerID] = true
}
filtered := make([]*db.Player, 0, len(availablePlayers))
for _, p := range availablePlayers {
if !faMap[p.ID] {
filtered = append(filtered, p)
}
}
availablePlayers = filtered
return true, nil
}); !ok {
return
}
if r.Method == "GET" {
renderSafely(seasonsview.SeasonLeagueFreeAgentsPage(season, league, freeAgents, availablePlayers), s, r, w)
} else {
renderSafely(seasonsview.SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers), s, r, w)
}
})
}
// RegisterFreeAgent handles POST to register a player as a free agent
func RegisterFreeAgent(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form"))
return
}
playerID := getter.Int("player_id").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data"))
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
// Verify player is not on a team in this season_league
players, err := db.GetPlayersNotOnTeam(ctx, tx, sl.Season.ID, sl.League.ID)
if err != nil {
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
}
playerFound := false
for _, p := range players {
if p.ID == playerID {
playerFound = true
break
}
}
if !playerFound {
notify.Warn(s, w, r, "Cannot Register", "Player is already on a team in this league.", nil)
return false, nil
}
// Check if already registered
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, sl.Season.ID, sl.League.ID, playerID)
if err != nil {
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
}
if isRegistered {
notify.Warn(s, w, r, "Already Registered", "Player is already registered as a free agent.", nil)
return false, nil
}
err = db.RegisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.RegisterFreeAgent")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Free Agent Registered", "Player has been registered as a free agent.", nil)
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
})
}
// UnregisterFreeAgent handles POST to unregister a player as a free agent
func UnregisterFreeAgent(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
respond.BadRequest(w, errors.New("failed to parse form"))
return
}
playerID := getter.Int("player_id").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
respond.BadRequest(w, errors.New("invalid form data"))
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
err = db.UnregisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.UnregisterFreeAgent")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Free Agent Removed", "Player has been unregistered as a free agent.", nil)
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
})
}
// NominateFreeAgentHandler handles POST to nominate a free agent for a fixture
func NominateFreeAgentHandler(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
playerID := getter.Int("player_id").Required().Value
teamID := getter.Int("team_id").Required().Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// Verify fixture exists and user is a manager
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
// Check if user can nominate: either a manager of the nominating team,
// or has fixtures.manage permission (can nominate for either team)
user := db.CurrentUser(ctx)
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if !canManage {
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule || userTeamID != teamID {
throw.Forbidden(s, w, r, "You must be a manager of the nominating team", nil)
return false, nil
}
}
// Verify the team_id is actually one of the fixture's teams
if teamID != fixture.HomeTeamID && teamID != fixture.AwayTeamID {
throw.BadRequest(s, w, r, "Invalid team for this fixture", nil)
return false, nil
}
// Verify player is a registered free agent in this season_league
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, playerID)
if err != nil {
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
}
if !isRegistered {
notify.Warn(s, w, r, "Not Registered", "Player is not a registered free agent in this league.", nil)
return false, nil
}
err = db.NominateFreeAgent(ctx, tx, fixtureID, playerID, teamID, db.NewAuditFromRequest(r))
if err != nil {
if db.IsBadRequest(err) {
notify.Warn(s, w, r, "Cannot Nominate", err.Error(), nil)
return false, nil
}
return false, errors.Wrap(err, "db.NominateFreeAgent")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Free Agent Nominated", "Free agent has been nominated for this fixture.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}
// RemoveFreeAgentNominationHandler handles POST to remove a free agent nomination
func RemoveFreeAgentNominationHandler(
s *hws.Server,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
return
}
playerID, err := strconv.Atoi(r.PathValue("player_id"))
if err != nil {
throw.BadRequest(s, w, r, "Invalid player ID", err)
return
}
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// Check if user can remove: either has fixtures.manage permission,
// or is a manager of the team that nominated the free agent
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
if !canManage {
fixture, err := db.GetFixture(ctx, tx, fixtureID)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
return false, nil
}
return false, errors.Wrap(err, "db.GetFixture")
}
user := db.CurrentUser(ctx)
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "fixture.CanSchedule")
}
if !canSchedule {
throw.Forbidden(s, w, r, "You must be a team manager to remove nominations", nil)
return false, nil
}
// Verify the nomination belongs to the user's team
nominations, err := db.GetNominatedFreeAgentsByTeam(ctx, tx, fixtureID, userTeamID)
if err != nil {
return false, errors.Wrap(err, "db.GetNominatedFreeAgentsByTeam")
}
found := false
for _, n := range nominations {
if n.PlayerID == playerID {
found = true
break
}
}
if !found {
throw.Forbidden(s, w, r, "You can only remove nominations made by your team", nil)
return false, nil
}
}
err := db.RemoveFreeAgentNomination(ctx, tx, fixtureID, playerID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.RemoveFreeAgentNomination")
}
return true, nil
}); !ok {
return
}
notify.Success(s, w, r, "Nomination Removed", "Free agent nomination has been removed.", nil)
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
})
}

View File

@@ -2,10 +2,12 @@ package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/pkg/errors"
"github.com/uptrace/bun"
@@ -15,23 +17,23 @@ import (
// Returns 200 OK if unique, 409 Conflict if not unique
func IsUnique(
s *hws.Server,
conn *bun.DB,
conn *db.DB,
model any,
field string,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, err := validation.ParseForm(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
respond.BadRequest(w, err)
return
}
value := getter.String(field).TrimSpace().Required().Value
if !getter.Validate() {
w.WriteHeader(http.StatusBadRequest)
respond.BadRequest(w, err)
return
}
unique := false
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, model, field, value)
if err != nil {
return false, errors.Wrap(err, "db.IsUnique")
@@ -41,9 +43,10 @@ func IsUnique(
return
}
if unique {
w.WriteHeader(http.StatusOK)
respond.OK(w)
} else {
w.WriteHeader(http.StatusConflict)
err := fmt.Errorf("'%s' is not unique for field '%s'", value, field)
respond.Conflict(w, err)
}
})
}

View File

@@ -14,11 +14,11 @@ import (
func LeaguesList(
s *hws.Server,
conn *bun.DB,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var leagues []*db.League
if ok := db.WithReadTx(s, w, r, conn, 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
leagues, err = db.GetLeagues(ctx, tx)
if err != nil {

View File

@@ -9,29 +9,24 @@ import (
"github.com/pkg/errors"
"github.com/uptrace/bun"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/validation"
leaguesview "git.haelnorr.com/h/oslstats/internal/view/leaguesview"
)
func NewLeague(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
renderSafely(leaguesview.NewPage(), s, r, w)
return
}
renderSafely(leaguesview.NewPage(), s, r, w)
})
}
func NewLeagueSubmit(
s *hws.Server,
conn *bun.DB,
audit *auditlog.Logger,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getter, ok := validation.ParseFormOrNotify(s, w, r)
@@ -53,7 +48,7 @@ func NewLeagueSubmit(
nameUnique := false
shortNameUnique := false
var league *db.League
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
nameUnique, err = db.IsUnique(ctx, tx, (*db.League)(nil), "name", name)
if err != nil {
@@ -66,14 +61,9 @@ func NewLeagueSubmit(
if !nameUnique || !shortNameUnique {
return true, nil
}
league = &db.League{
Name: name,
ShortName: shortname,
Description: description,
}
err = db.Insert(tx, league).WithAudit(r, audit.Callback()).Exec(ctx)
league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.Insert")
return false, errors.Wrap(err, "db.NewLeague")
}
return true, nil
}); !ok {
@@ -89,8 +79,7 @@ func NewLeagueSubmit(
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/leagues/%s", league.ShortName))
w.WriteHeader(http.StatusOK)
respond.HXRedirect(w, "/leagues/%s", league.ShortName)
notify.SuccessWithDelay(s, w, r, "League Created", fmt.Sprintf("Successfully created league: %s", name), nil)
})
}

View File

@@ -7,11 +7,12 @@ import (
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws"
"github.com/pkg/errors"
"github.com/uptrace/bun"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"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/throw"
"git.haelnorr.com/h/oslstats/pkg/oauth"
@@ -19,7 +20,7 @@ import (
func Login(
s *hws.Server,
conn *bun.DB,
conn *db.DB,
cfg *config.Config,
st *store.Store,
discordAPI *discord.APIClient,
@@ -34,10 +35,10 @@ func Login(
if r.Method == "POST" {
if err != nil {
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
w.WriteHeader(http.StatusOK)
respond.OK(w)
return
}
w.Header().Set("HX-Redirect", "/login")
respond.HXRedirect(w, "/login")
return
}

View File

@@ -8,6 +8,7 @@ import (
"git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"github.com/pkg/errors"
"github.com/uptrace/bun"
@@ -16,18 +17,13 @@ import (
func Logout(
s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB,
conn *db.DB,
discordAPI *discord.APIClient,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
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 := db.WithWriteTx(s, w, r, conn, 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)
if err != nil {
return false, errors.Wrap(err, "user.DeleteDiscordTokens")
@@ -48,7 +44,7 @@ func Logout(
}); !ok {
return
}
w.Header().Set("HX-Redirect", "/")
respond.HXRedirect(w, "/")
},
)
}

View File

@@ -1,45 +0,0 @@
package handlers
import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/uptrace/bun"
)
// pageOptsFromForm calls r.ParseForm and gets the pageOpts from the formdata.
// It renders a Bad Request error page on fail
// PageOpts will be nil on fail
func pageOptsFromForm(s *hws.Server, w http.ResponseWriter, r *http.Request) *db.PageOpts {
getter, ok := validation.ParseFormOrError(s, w, r)
if !ok {
return nil
}
return getPageOpts(s, w, r, getter)
}
// pageOptsFromQuery gets the pageOpts from the request query and renders a Bad Request error page on fail
// PageOpts will be nil on fail
func pageOptsFromQuery(s *hws.Server, w http.ResponseWriter, r *http.Request) *db.PageOpts {
return getPageOpts(s, w, r, validation.NewQueryGetter(r))
}
func getPageOpts(s *hws.Server, w http.ResponseWriter, r *http.Request, g validation.Getter) *db.PageOpts {
page := g.Int("page").Optional().Min(1).Value
perPage := g.Int("per_page").Optional().Min(1).Max(100).Value
order := g.String("order").TrimSpace().ToUpper().Optional().AllowedValues([]string{"ASC", "DESC"}).Value
orderBy := g.String("order_by").TrimSpace().Optional().ToLower().Value
valid := g.ValidateAndError(s, w, r)
if !valid {
return nil
}
pageOpts := &db.PageOpts{
Page: page,
PerPage: perPage,
Order: bun.Order(order),
OrderBy: orderBy,
}
return pageOpts
}

View File

@@ -12,15 +12,20 @@ import (
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/internal/throw"
authview "git.haelnorr.com/h/oslstats/internal/view/authview"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
)
func Register(
s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB,
conn *db.DB,
slapAPI *slapshotapi.SlapAPI,
cfg *config.Config,
store *store.Store,
) http.Handler {
@@ -55,7 +60,8 @@ func Register(
username := r.FormValue("username")
unique := false
var user *db.User
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
if err != nil {
return false, errors.Wrap(err, "db.IsUsernameUnique")
@@ -63,26 +69,20 @@ func Register(
if !unique {
return true, nil
}
user, err = db.CreateUser(ctx, tx, username, details.DiscordUser)
user, err = registerUser(ctx, tx, username, details, cfg.RBAC, audit)
if err != nil {
return false, errors.Wrap(err, "db.CreateUser")
return false, errors.Wrap(err, "registerUser")
}
err = user.UpdateDiscordToken(ctx, tx, details.Token)
err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
if err != nil {
return false, errors.Wrap(err, "db.UpdateDiscordToken")
}
if shouldGrantAdmin(user, cfg.RBAC) {
err := ensureUserHasAdminRole(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "ensureUserHasAdminRole")
}
return false, errors.Wrap(err, "connectSlapID")
}
return true, nil
}); !ok {
return
}
if !unique {
w.WriteHeader(http.StatusConflict)
respond.Conflict(w, errors.New("username is taken"))
} else {
err = auth.Login(w, r, user, true)
if err != nil {
@@ -90,8 +90,67 @@ func Register(
return
}
pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom)
respond.HXRedirect(w, "%s", pageFrom)
}
},
)
}
func registerUser(ctx context.Context, tx bun.Tx,
username string, details *store.RegistrationSession,
rbac *rbac.Config, audit *db.AuditMeta,
) (*db.User, error) {
// Register the user
user, err := db.CreateUser(ctx, tx, username, details.DiscordUser, audit)
if err != nil {
return nil, errors.Wrap(err, "db.CreateUser")
}
err = user.UpdateDiscordToken(ctx, tx, details.Token)
if err != nil {
return nil, errors.Wrap(err, "db.UpdateDiscordToken")
}
err = user.ConnectPlayer(ctx, tx, audit)
if err != nil {
return nil, errors.Wrap(err, "db.ConnectPlayer")
}
// Check if they should be an admin
if shouldGrantAdmin(user, rbac) {
err := ensureUserHasAdminRole(ctx, tx, user)
if err != nil {
return nil, errors.Wrap(err, "ensureUserHasAdminRole")
}
}
return user, nil
}
func connectSlapID(ctx context.Context, tx bun.Tx, user *db.User,
token *discord.Token, slapAPI *slapshotapi.SlapAPI, audit *db.AuditMeta,
) error {
// Attempt to setup their player/slapID from steam connection
// If fails due to no steam connection or no slapID, fail silently and proceed with registration
session, err := discord.NewOAuthSession(token)
if err != nil {
return errors.Wrap(err, "discord.NewOAuthSession")
}
steamID, err := session.GetSteamID()
if err != nil {
if err == discord.ErrNoSteam {
return nil
}
return errors.Wrap(err, "session.GetSteamID")
}
slapID, err := slapAPI.GetSlapID(ctx, steamID)
if err != nil {
if err == slapshotapi.ErrNoSlapID {
return nil
}
return errors.Wrap(err, "slapAPI.GetSlapID")
}
// slapID exists, we can update their player connection
err = db.UpdatePlayerSlapID(ctx, tx, user.Player.ID, slapID, audit)
if err != nil {
return errors.Wrap(err, "db.UpdatePlayerSlapID")
}
return nil
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
@@ -14,22 +15,23 @@ import (
func SeasonPage(
s *hws.Server,
conn *bun.DB,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
var season *db.Season
var leaguesWithTeams []db.LeagueWithTeams
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
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")
}
if season == nil {
return true, nil
}
leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx)
if err != nil {
@@ -40,9 +42,8 @@ func SeasonPage(
}); !ok {
return
}
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
if season.Type == db.SeasonTypeDraft.String() {
http.Redirect(w, r, fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, "Draft"), http.StatusTemporaryRedirect)
}
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
})

View File

@@ -6,9 +6,9 @@ import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw"
"git.haelnorr.com/h/oslstats/internal/validation"
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
@@ -19,16 +19,20 @@ import (
func SeasonEditPage(
s *hws.Server,
conn *bun.DB,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
var season *db.Season
var allLeagues []*db.League
if ok := db.WithReadTx(s, w, r, conn, 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
season, err = db.GetSeason(ctx, tx, seasonStr)
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")
}
allLeagues, err = db.GetLeagues(ctx, tx)
@@ -39,18 +43,13 @@ func SeasonEditPage(
}); !ok {
return
}
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.EditPage(season, allLeagues), s, r, w)
})
}
func SeasonEditSubmit(
s *hws.Server,
conn *bun.DB,
audit *auditlog.Logger,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
@@ -77,34 +76,26 @@ func SeasonEditSubmit(
}
var season *db.Season
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
if db.IsBadRequest(err) {
respond.NotFound(w, err)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeason")
}
if season == nil {
return false, errors.New("season does not exist")
}
season.Update(version, start, end, finalsStart, finalsEnd)
err = db.Update(tx, season).WherePK().
Column("slap_version", "start_date", "end_date", "finals_start_date", "finals_end_date").
WithAudit(r, audit.Callback()).Exec(ctx)
err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.Update")
return false, errors.Wrap(err, "season.Update")
}
return true, nil
}); !ok {
return
}
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
w.WriteHeader(http.StatusOK)
respond.HXRedirect(w, "/seasons/%s", season.ShortName)
notify.SuccessWithDelay(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
})
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/validation"
@@ -16,12 +15,11 @@ import (
func SeasonLeagueAddTeam(
s *hws.Server,
conn *bun.DB,
audit *auditlog.Logger,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
seasonShortName := r.PathValue("season_short_name")
leagueShortName := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
@@ -36,80 +34,23 @@ func SeasonLeagueAddTeam(
var league *db.League
var team *db.Team
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
// Get season
season, err = db.GetSeason(ctx, tx, seasonStr)
team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonShortName, leagueShortName, teamID, db.NewAuditFromRequest(r))
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
if db.IsBadRequest(err) {
w.WriteHeader(http.StatusBadRequest)
return false, nil
}
return false, errors.Wrap(err, "db.NewTeamParticipation")
}
if season == nil {
notify.Warn(s, w, r, "Not Found", "Season not found.", nil)
return false, nil
}
// Get league
league, err = db.GetLeague(ctx, tx, leagueStr)
if err != nil {
return false, errors.Wrap(err, "db.GetLeague")
}
if league == nil {
notify.Warn(s, w, r, "Not Found", "League not found.", nil)
return false, nil
}
if !season.HasLeague(league.ID) {
notify.Warn(s, w, r, "Invalid League", "This league is not associated with this season.", nil)
return false, nil
}
// Get team
team, err = db.GetTeam(ctx, tx, teamID)
if err != nil {
return false, errors.Wrap(err, "db.GetTeam")
}
if team == nil {
notify.Warn(s, w, r, "Not Found", "Team not found.", nil)
return false, nil
}
// Check if team is already in this season (in any league)
var tpCount int
tpCount, err = tx.NewSelect().
Model((*db.TeamParticipation)(nil)).
Where("season_id = ? AND team_id = ?", season.ID, team.ID).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
if tpCount > 0 {
notify.Warn(s, w, r, "Already In Season", fmt.Sprintf(
"Team '%s' is already participating in this season.",
team.Name,
), nil)
return false, nil
}
// Add team to league
participation := &db.TeamParticipation{
SeasonID: season.ID,
LeagueID: league.ID,
TeamID: team.ID,
}
err = db.Insert(tx, participation).WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil {
return false, errors.Wrap(err, "db.Insert")
}
return true, nil
}); !ok {
return
}
// Redirect to refresh the page
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, league.ShortName))
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName))
w.WriteHeader(http.StatusOK)
notify.Success(s, w, r, "Team Added", fmt.Sprintf(
"Successfully added '%s' to the league.",

View File

@@ -2,52 +2,47 @@ package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/throw"
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// SeasonLeaguePage redirects to the appropriate default tab based on season status
func SeasonLeaguePage(
s *hws.Server,
conn *bun.DB,
conn *db.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name")
var season *db.Season
var league *db.League
var teams []*db.Team
var allTeams []*db.Team
var sl *db.SeasonLeague
if ok := db.WithReadTx(s, w, r, conn, 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
season, league, teams, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
sl, err = db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeasonLeague")
}
// Get all teams for the dropdown (to add teams)
allTeams, err = db.GetList[db.Team](tx).GetAll(ctx)
if err != nil {
return false, errors.Wrap(err, "db.GetList[Team]")
}
return true, nil
}); !ok {
return
}
if season == nil || league == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.SeasonLeaguePage(season, league, teams, allTeams), s, r, w)
defaultTab := sl.Season.GetDefaultTab()
redirectURL := fmt.Sprintf(
"/seasons/%s/leagues/%s/%s",
seasonStr, leagueStr, defaultTab,
)
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
})
}

Some files were not shown because too many files have changed in this diff Show More