Compare commits

...

33 Commits

Author SHA1 Message Date
ac74f3197b players now created with a name 2026-03-05 18:25:32 +11:00
5faaf5e959 Merge branch 'xforwarded-fix' into development 2026-03-05 18:09:34 +11:00
c49d58f9b7 fixed remoteip in auditlogs not using correct value 2026-03-05 18:09:15 +11:00
02cb57eb7e removed migration validation as it is redudant 2026-02-25 23:05:52 +11:00
9b2eba76a7 updated ezconf 2026-02-25 22:47:33 +11:00
h
d0c1b53d87 Merge pull request 'fixtures' (#2) from fixtures into master
Reviewed-on: #2
2026-02-23 20:38:26 +11:00
3c866551a4 added free agents 2026-02-22 22:44:17 +11:00
4185ab58e2 added league table 2026-02-21 23:11:57 +11:00
1c93a707ab added stats to team page 2026-02-21 22:55:46 +11:00
680ba3fe50 added log file uploading and match results 2026-02-21 22:25:21 +11:00
6439bf782b updated slapapi 2026-02-21 14:45:14 +11:00
971960d0cb added fixture scheduling 2026-02-21 14:19:46 +11:00
9e3355deb6 added team rosters to season_league overview 2026-02-20 20:08:57 +11:00
7ea21c63e4 added team view to season_leagues 2026-02-20 19:57:06 +11:00
c3d8e6c675 added player names 2026-02-19 20:48:31 +11:00
71373c4747 made draft seasons redirect to season_league view 2026-02-19 19:18:39 +11:00
667c9f04a7 removed print from preview_middleware 2026-02-19 19:01:58 +11:00
4b81ac12cf Revert partial changes from 9db855f, keeping changes to preview_middleware and teams_new 2026-02-19 19:01:07 +11:00
52a168f1aa fixed bug with fixture generation 2026-02-19 18:49:02 +11:00
7d5949af1e added an autoallocate fixtures function 2026-02-18 21:15:16 +11:00
9db855f45b draft seasons get special treatment :) 2026-02-18 20:36:15 +11:00
2db24c3f77 added season types and changed new season to be a modal 2026-02-18 19:43:54 +11:00
42282d05b1 slapid and player now links when registering 2026-02-17 18:33:22 +11:00
9362448f22 added slapapi 2026-02-17 08:12:07 +11:00
f8090aa0cc added players 2026-02-16 21:31:02 +11:00
bb3bed3e89 im actually goated 2026-02-16 00:29:22 +11:00
h
3b430d39e2 Merge pull request 'league' (#1) from league into master
Reviewed-on: #1
2026-02-15 19:59:31 +11:00
0c5a88c309 we have fixtures ladies and gentleman 2026-02-15 19:56:03 +11:00
ef8c022e60 everybody loves a refactor 2026-02-15 12:27:36 +11:00
61890ae20b updated season league view page 2026-02-14 21:08:00 +11:00
4a2396bca8 big ole refactor 2026-02-14 19:48:59 +11:00
0fc3bb0c94 admin page updates 2026-02-14 14:54:06 +11:00
55f79176cc admin page updates 2026-02-13 20:51:39 +11:00
188 changed files with 16414 additions and 2711 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env .env
.test.env
*.db* *.db*
.logs/ .logs/
server.log 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/golib/hlog"
"git.haelnorr.com/h/oslstats/internal/config" "git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db/migrate"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -48,7 +49,7 @@ func main() {
// Handle migration file creation (doesn't need DB connection) // Handle migration file creation (doesn't need DB connection)
if flags.MigrateCreate != "" { 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") logger.Fatal().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "createMigration"))).Msg("Error creating migration")
} }
return return
@@ -59,17 +60,21 @@ func main() {
flags.MigrateStatus || flags.MigrateDryRun || flags.MigrateStatus || flags.MigrateDryRun ||
flags.ResetDB { flags.ResetDB {
var command, countStr string
// Route to appropriate command // Route to appropriate command
if flags.MigrateUp != "" { if flags.MigrateUp != "" {
err = runMigrations(ctx, cfg, "up", flags.MigrateUp) command = "up"
countStr = flags.MigrateUp
} else if flags.MigrateRollback != "" { } else if flags.MigrateRollback != "" {
err = runMigrations(ctx, cfg, "rollback", flags.MigrateRollback) command = "rollback"
countStr = flags.MigrateRollback
} else if flags.MigrateStatus { } else if flags.MigrateStatus {
err = runMigrations(ctx, cfg, "status", "") command = "status"
} else if flags.MigrateDryRun { }
err = runMigrations(ctx, cfg, "dry-run", "") if flags.ResetDB {
} else if flags.ResetDB { err = migrate.ResetDatabase(ctx, cfg)
err = resetDatabase(ctx, cfg) } else {
err = migrate.RunMigrations(ctx, cfg, command, countStr)
} }
if err != nil { 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" "github.com/pkg/errors"
"git.haelnorr.com/h/oslstats/internal/config" "git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/embedfs" "git.haelnorr.com/h/oslstats/internal/embedfs"
"git.haelnorr.com/h/oslstats/internal/server"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
) )
// Initializes and runs the server // Initializes and runs the server
@@ -25,8 +28,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
// Setup the database connection // Setup the database connection
logger.Debug().Msg("Config loaded and logger started") logger.Debug().Msg("Config loaded and logger started")
logger.Debug().Msg("Connecting to database") logger.Debug().Msg("Connecting to database")
bun, closedb := setupBun(cfg) conn := db.NewDB(cfg.DB)
// registerDBModels(bun)
// Setup embedded files // Setup embedded files
logger.Debug().Msg("Getting 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") return errors.Wrap(err, "discord.NewAPIClient")
} }
// Setup Slapshot API
logger.Debug().Msg("Setting up Slapshot API client")
slapAPI, err := slapshotapi.NewSlapAPIClient(cfg.Slapshot)
if err != nil {
return errors.Wrap(err, "slapshotapi.NewSlapAPIClient")
}
logger.Debug().Msg("Setting up HTTP server") logger.Debug().Msg("Setting up HTTP server")
httpServer, err := setupHTTPServer(&staticFS, cfg, logger, bun, store, discordAPI) httpServer, err := server.Setup(staticFS, cfg, logger, conn, store, discordAPI, slapAPI)
if err != nil { if err != nil {
return errors.Wrap(err, "setupHttpServer") return errors.Wrap(err, "setupHttpServer")
} }
@@ -71,7 +80,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
if err != nil { if err != nil {
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Error during HTTP server shutdown") 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 { if err != nil {
logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "closedb"))).Msg("Error during database close") 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 ( require (
git.haelnorr.com/h/golib/env v0.9.1 git.haelnorr.com/h/golib/env v0.9.1
git.haelnorr.com/h/golib/ezconf v0.1.1 git.haelnorr.com/h/golib/ezconf v0.2.1
git.haelnorr.com/h/golib/hlog v0.10.4 git.haelnorr.com/h/golib/hlog v0.11.0
git.haelnorr.com/h/golib/hws v0.5.0 git.haelnorr.com/h/golib/hws v0.6.0
git.haelnorr.com/h/golib/hwsauth v0.6.1 git.haelnorr.com/h/golib/hwsauth v0.7.0
git.haelnorr.com/h/golib/notify v0.1.0 git.haelnorr.com/h/golib/notify v0.1.0
github.com/a-h/templ v0.3.977 github.com/a-h/templ v0.3.977
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
@@ -16,6 +16,7 @@ require (
github.com/uptrace/bun v1.2.16 github.com/uptrace/bun v1.2.16
github.com/uptrace/bun/dialect/pgdialect v1.2.16 github.com/uptrace/bun/dialect/pgdialect v1.2.16
github.com/uptrace/bun/driver/pgdriver v1.2.16 github.com/uptrace/bun/driver/pgdriver v1.2.16
golang.org/x/time v0.14.0
) )
require ( require (

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/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 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= 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.2.1 h1:axMyKtgO9Zk6E8CrYrLpMzifvpjz73yxCQq0lOtuhck=
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8= git.haelnorr.com/h/golib/ezconf v0.2.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.11.0 h1:tCT8HWs51Nbin58sCTLcq5re6CZqo5/IHCzk3G+S3vQ=
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= git.haelnorr.com/h/golib/hlog v0.11.0/go.mod h1:HjhXS5G3A0BwOZq7nu2qpNBtvOFiCa1GbAuBRxAkYqs=
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c= git.haelnorr.com/h/golib/hws v0.6.0 h1:jwXUqT03PfrexVAC0xKVQWT2CLwN8+TDBsCK3+JWmEE=
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM= git.haelnorr.com/h/golib/hws v0.6.0/go.mod h1:iAjdrwYZye2nsvbBGIMzVcfydV4F47qUp10MvimVCOY=
git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0= git.haelnorr.com/h/golib/hwsauth v0.7.0 h1:2uR7douZfkJ1vORUpvtS50FgGNm0GextDyMlCtrStbo=
git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA= 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 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= 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= 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/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

View File

@@ -10,4 +10,5 @@ func (c Key) String() string {
var ( var (
DevModeKey Key = Key("devmode") DevModeKey Key = Key("devmode")
PermissionCacheKey Key = Key("permissions") 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 package db
import ( import (
"context"
"net/http" "net/http"
"reflect" "reflect"
"strings" "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 // AuditInfo contains metadata for audit logging
type AuditInfo struct { type AuditInfo struct {
Action string // e.g., "seasons.create", "users.update" Action string // e.g., "seasons.create", "users.update"
ResourceType string // e.g., "season", "user" ResourceType string // e.g., "season", "user"
ResourceID any // Primary key value (int, string, etc.) ResourceID any // Primary key value (int, string, etc.)
Details map[string]any // Changed fields or additional metadata Details any // Changed fields or additional metadata
} }
// 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 // extractTableName gets the bun table name from a model type using reflection
// Example: Season with `bun:"table:seasons,alias:s"` returns "seasons" // Example: Season with `bun:"table:seasons,alias:s"` returns "seasons"
func extractTableName[T any]() string { func extractTableName[T any]() string {
@@ -27,7 +36,7 @@ func extractTableName[T any]() string {
t := reflect.TypeOf(model) t := reflect.TypeOf(model)
// Handle pointer types // Handle pointer types
if t.Kind() == reflect.Ptr { if t.Kind() == reflect.Pointer {
t = t.Elem() t = t.Elem()
} }
@@ -38,10 +47,43 @@ func extractTableName[T any]() string {
bunTag := field.Tag.Get("bun") bunTag := field.Tag.Get("bun")
if bunTag != "" { if bunTag != "" {
// Parse tag: "table:seasons,alias:s" -> "seasons" // Parse tag: "table:seasons,alias:s" -> "seasons"
parts := strings.Split(bunTag, ",") for part := range strings.SplitSeq(bunTag, ",") {
for _, part := range parts { part, match := strings.CutPrefix(part, "table:")
if strings.HasPrefix(part, "table:") { if match {
return strings.TrimPrefix(part, "table:") 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) v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr { if v.Kind() == reflect.Pointer {
v = v.Elem() v = v.Elem()
} }
@@ -110,7 +152,7 @@ func extractChangedFields[T any](model *T, columns []string) map[string]any {
result := make(map[string]any) result := make(map[string]any)
v := reflect.ValueOf(model) v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr { if v.Kind() == reflect.Pointer {
v = v.Elem() v = v.Elem()
} }
@@ -142,5 +184,3 @@ func extractChangedFields[T any](model *T, columns []string) map[string]any {
return result 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 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 { func (a *AuditLogFilter) DateRange(start, end int64) *AuditLogFilter {
if start > 0 { if start > 0 {
a.GreaterEqualThan("al.created_at", start) 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) { func GetAuditLogs(ctx context.Context, tx bun.Tx, pageOpts *PageOpts, filters *AuditLogFilter) (*List[AuditLog], error) {
defaultPageOpts := &PageOpts{ defaultPageOpts := &PageOpts{
Page: 1, Page: 1,
PerPage: 15, PerPage: 10,
Order: bun.OrderDesc, Order: bun.OrderDesc,
OrderBy: "created_at", OrderBy: "created_at",
} }
@@ -119,7 +147,7 @@ func GetAuditLogByID(ctx context.Context, tx bun.Tx, id int) (*AuditLog, error)
if id <= 0 { if id <= 0 {
return nil, errors.New("id must be positive") 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 // 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 ( import (
"context" "context"
@@ -9,14 +9,13 @@ import (
"sort" "sort"
"time" "time"
"git.haelnorr.com/h/oslstats/internal/config"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
// CreateBackup creates a compressed PostgreSQL dump before migrations // CreateBackup creates a compressed PostgreSQL dump before migrations
// Returns backup filename and error // Returns backup filename and error
// If pg_dump is not available, returns nil error with warning // 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 // Check if pg_dump is available
if _, err := exec.LookPath("pg_dump"); err != nil { if _, err := exec.LookPath("pg_dump"); err != nil {
fmt.Println("[WARN] pg_dump not found - skipping backup") 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 // 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") return "", errors.Wrap(err, "failed to create backup directory")
} }
// Generate filename: YYYYMMDD_HHmmss_pre_{operation}.sql.gz // Generate filename: YYYYMMDD_HHmmss_pre_{operation}.sql.gz
timestamp := time.Now().Format("20060102_150405") 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)) fmt.Sprintf("%s_pre_%s.sql.gz", timestamp, operation))
// Check if gzip is available // 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 { if _, err := exec.LookPath("gzip"); err != nil {
fmt.Println("[WARN] gzip not found - using uncompressed backup") fmt.Println("[WARN] gzip not found - using uncompressed backup")
useGzip = false useGzip = false
filename = filepath.Join(cfg.DB.BackupDir, filename = filepath.Join(cfg.BackupDir,
fmt.Sprintf("%s_pre_%s.sql", timestamp, operation)) 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 // Use shell to pipe pg_dump through gzip
pgDumpCmd := fmt.Sprintf( pgDumpCmd := fmt.Sprintf(
"pg_dump -h %s -p %d -U %s -d %s --no-owner --no-acl --clean --if-exists | gzip > %s", "pg_dump -h %s -p %d -U %s -d %s --no-owner --no-acl --clean --if-exists | gzip > %s",
cfg.DB.Host, cfg.Host,
cfg.DB.Port, cfg.Port,
cfg.DB.User, cfg.User,
cfg.DB.DB, cfg.DB,
filename, filename,
) )
cmd = exec.CommandContext(ctx, "sh", "-c", pgDumpCmd) cmd = exec.CommandContext(ctx, "sh", "-c", pgDumpCmd)
} else { } else {
cmd = exec.CommandContext(ctx, "pg_dump", cmd = exec.CommandContext(ctx, "pg_dump",
"-h", cfg.DB.Host, "-h", cfg.Host,
"-p", fmt.Sprint(cfg.DB.Port), "-p", fmt.Sprint(cfg.Port),
"-U", cfg.DB.User, "-U", cfg.User,
"-d", cfg.DB.DB, "-d", cfg.DB,
"-f", filename, "-f", filename,
"--no-owner", "--no-owner",
"--no-acl", "--no-acl",
@@ -75,7 +74,7 @@ func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (st
// Set password via environment variable // Set password via environment variable
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
fmt.Sprintf("PGPASSWORD=%s", cfg.DB.Password)) fmt.Sprintf("PGPASSWORD=%s", cfg.Password))
// Run backup // Run backup
if err := cmd.Run(); err != nil { 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 // 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) // 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 { if err != nil {
return errors.Wrap(err, "failed to list .sql backups") 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 { if err != nil {
return errors.Wrap(err, "failed to list .sql.gz backups") return errors.Wrap(err, "failed to list .sql.gz backups")
} }

View File

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

View File

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

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

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

View File

@@ -1,41 +1,11 @@
package db package db
import ( import (
"runtime" "git.haelnorr.com/h/golib/ezconf"
"strings"
) )
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct {
configFunc func() (any, error)
name string
}
// PackagePath returns the path to the config package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return e.configFunc()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return strings.ToLower(e.name)
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return e.name
}
// NewEZConfIntegration creates a new EZConf integration helper // NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration { func NewEZConfIntegration() *ezconf.Integration {
return EZConfIntegration{name: "DB", configFunc: ConfigFromEnv} 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) Scan(ctx)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil resource := extractResourceType(extractTableName[T]())
return nil, BadRequestNotFound(resource, g.field, g.value)
} }
return nil, errors.Wrap(err, "bun.SelectQuery.Scan") return nil, errors.Wrap(err, "bun.SelectQuery.Scan")
} }
@@ -36,6 +37,10 @@ func (g *fieldgetter[T]) Get(ctx context.Context) (*T, error) {
return g.get(ctx) return g.get(ctx)
} }
func (g *fieldgetter[T]) String() string {
return g.q.String()
}
func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] { func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] {
g.q = g.q.Relation(name, apply...) g.q = g.q.Relation(name, apply...)
return g return g
@@ -65,5 +70,6 @@ func GetByID[T any](
tx bun.Tx, tx bun.Tx,
id int, id int,
) *fieldgetter[T] { ) *fieldgetter[T] {
return GetByField[T](tx, "id", id) prefix := extractTableAlias[T]()
return GetByField[T](tx, prefix+".id", id)
} }

View File

@@ -33,6 +33,7 @@ const (
LessEqual Comparator = "<=" LessEqual Comparator = "<="
Greater Comparator = ">" Greater Comparator = ">"
GreaterEqual Comparator = ">=" GreaterEqual Comparator = ">="
In Comparator = "IN"
) )
type ListFilter struct { type ListFilter struct {
@@ -63,6 +64,10 @@ func (f *ListFilter) GreaterEqualThan(field string, value any) {
f.filters = append(f.filters, Filter{field, value, GreaterEqual}) 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] { func GetList[T any](tx bun.Tx) *listgetter[T] {
l := &listgetter[T]{ l := &listgetter[T]{
items: new([]*T), items: new([]*T),
@@ -72,6 +77,10 @@ func GetList[T any](tx bun.Tx) *listgetter[T] {
return l return l
} }
func (l *listgetter[T]) String() string {
return l.q.String()
}
func (l *listgetter[T]) Join(join string, args ...any) *listgetter[T] { func (l *listgetter[T]) Join(join string, args ...any) *listgetter[T] {
l.q = l.q.Join(join, args...) l.q = l.q.Join(join, args...)
return l return l
@@ -82,6 +91,11 @@ func (l *listgetter[T]) Where(query string, args ...any) *listgetter[T] {
return l return l
} }
func (l *listgetter[T]) Order(orders ...string) *listgetter[T] {
l.q = l.q.Order(orders...)
return l
}
func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *listgetter[T] { func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *listgetter[T] {
l.q = l.q.Relation(name, apply...) l.q = l.q.Relation(name, apply...)
return l return l
@@ -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] { func (l *listgetter[T]) Filter(filters ...Filter) *listgetter[T] {
for _, filter := range filters { 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 return l
} }
@@ -115,6 +133,14 @@ func (l *listgetter[T]) GetPaged(ctx context.Context, pageOpts, defaults *PageOp
return list, nil return list, nil
} }
func (l *listgetter[T]) Count(ctx context.Context) (int, error) {
count, err := l.q.Count(ctx)
if err != nil {
return 0, errors.Wrap(err, "query.Count")
}
return count, nil
}
func (l *listgetter[T]) GetAll(ctx context.Context) ([]*T, error) { func (l *listgetter[T]) GetAll(ctx context.Context) ([]*T, error) {
err := l.q.Scan(ctx) err := l.q.Scan(ctx)
if err != nil && errors.Is(err, sql.ErrNoRows) { if err != nil && errors.Is(err, sql.ErrNoRows) {

View File

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

View File

@@ -10,20 +10,13 @@ import (
type League struct { type League struct {
bun.BaseModel `bun:"table:leagues,alias:l"` bun.BaseModel `bun:"table:leagues,alias:l"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull"` ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
Description string `bun:"description"` Description string `bun:"description" json:"description"`
Seasons []Season `bun:"m2m:season_leagues,join:League=Season"` Seasons []Season `bun:"m2m:season_leagues,join:League=Season" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:League=Team"` Teams []Team `bun:"m2m:team_participations,join:League=Team" json:"-"`
}
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"`
} }
func GetLeagues(ctx context.Context, tx bun.Tx) ([]*League, error) { 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) return GetByField[League](tx, "short_name", shortname).Relation("Seasons").Get(ctx)
} }
// GetSeasonLeague retrieves a specific season-league combination with teams func NewLeague(ctx context.Context, tx bun.Tx, name, shortname, description string, audit *AuditMeta) (*League, error) {
func GetSeasonLeague(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string) (*Season, *League, []*Team, error) { league := &League{
if seasonShortName == "" { Name: name,
return nil, nil, nil, errors.New("season short_name cannot be empty") ShortName: shortname,
Description: description,
} }
if leagueShortName == "" { err := Insert(tx, league).
return nil, nil, nil, errors.New("league short_name cannot be empty") WithAudit(audit, nil).Exec(ctx)
}
// Get the season
season, err := GetSeason(ctx, tx, seasonShortName)
if err != nil { if err != nil {
return nil, nil, nil, errors.Wrap(err, "GetSeason") return nil, errors.Wrap(err, "db.Insert")
} }
return league, nil
// 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
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,15 +10,15 @@ import (
func init() { func init() {
Migrations.MustRegister( Migrations.MustRegister(
// UP migration // UP migration
func(ctx context.Context, dbConn *bun.DB) error { func(ctx context.Context, conn *bun.DB) error {
// Add your migration code here // Add your migration code here
_, err := dbConn.NewCreateTable(). _, err := conn.NewCreateTable().
Model((*db.Team)(nil)). Model((*db.Team)(nil)).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return err return err
} }
_, err = dbConn.NewCreateTable(). _, err = conn.NewCreateTable().
Model((*db.TeamParticipation)(nil)). Model((*db.TeamParticipation)(nil)).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
@@ -27,16 +27,16 @@ func init() {
return nil return nil
}, },
// DOWN migration // DOWN migration
func(ctx context.Context, dbConn *bun.DB) error { func(ctx context.Context, conn *bun.DB) error {
// Add your rollback code here // Add your rollback code here
_, err := dbConn.NewDropTable(). _, err := conn.NewDropTable().
Model((*db.TeamParticipation)(nil)). Model((*db.TeamParticipation)(nil)).
IfExists(). IfExists().
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return err return err
} }
_, err = dbConn.NewDropTable(). _, err = conn.NewDropTable().
Model((*db.Team)(nil)). Model((*db.Team)(nil)).
IfExists(). IfExists().
Exec(ctx) 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 package db
import ( import (
"net/http"
"strings" "strings"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/validation"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -19,6 +22,41 @@ type OrderOpts struct {
Label string 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) { func setPageOpts(q *bun.SelectQuery, p, d *PageOpts, totalitems int) (*bun.SelectQuery, *PageOpts) {
if p == nil { if p == nil {
p = new(PageOpts) 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 // GetPermissionByName queries the database for a permission matching the given name
// Returns nil, nil if no permission is found // Returns a BadRequestNotFound error if no permission is found
func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permission) (*Permission, error) { func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permission) (*Permission, error) {
if name == "" { if name == "" {
return nil, errors.New("name cannot be empty") return nil, errors.New("name cannot be empty")
@@ -37,7 +37,7 @@ func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permis
} }
// GetPermissionByID queries the database for a permission matching the given ID // GetPermissionByID queries the database for a permission matching the given ID
// Returns nil, nil if no permission is found // Returns a BadRequestNotFound error if no permission is found
func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, error) { func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, error) {
if id <= 0 { if id <= 0 {
return nil, errors.New("id must be positive") return nil, errors.New("id must be positive")
@@ -92,5 +92,5 @@ func DeletePermission(ctx context.Context, tx bun.Tx, id int) error {
if id <= 0 { if id <= 0 {
return errors.New("id must be positive") 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"` 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 { func (r Role) isSystem() bool {
return r.IsSystem return r.IsSystem
} }
// GetRoleByName queries the database for a role matching the given name // GetRoleByName queries the database for a role matching the given name
// Returns nil, nil if no role is found // Returns a BadRequestNotFound error if no role is found
func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, error) { func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, error) {
if name == "" { if name == "" {
return nil, errors.New("name cannot be empty") return nil, errors.New("name cannot be empty")
} }
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 // GetRoleByID queries the database for a role matching the given ID
// Returns nil, nil if no role is found // Returns a BadRequestNotFound error if no role is found
func GetRoleByID(ctx context.Context, tx bun.Tx, id int) (*Role, error) { func GetRoleByID(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
return GetByID[Role](tx, id).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) 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) 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 // 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 { if role == nil {
return errors.New("role cannot be 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). err := Insert(tx, role).
Returning("id"). Returning("id").
WithAudit(audit, nil).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "db.Insert") 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 // 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 { if role == nil {
return errors.New("role cannot be 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). err := Update(tx, role).
WherePK(). WherePK().
WithAudit(audit, nil).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "db.Update") 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) // 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 { if id <= 0 {
return errors.New("id must be positive") return errors.New("id must be positive")
} }
return DeleteWithProtection[Role](ctx, tx, id)
}
// AddPermissionToRole grants a permission to a role // First check if role exists and is not system
func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error { role, err := GetRoleByID(ctx, tx, id)
if roleID <= 0 { if err != nil {
return errors.New("roleID must be positive") return errors.Wrap(err, "GetRoleByID")
} }
if permissionID <= 0 { if role.IsSystem {
return errors.New("permissionID must be positive") return errors.New("cannot delete system roles")
} }
rolePerm := &RolePermission{
RoleID: roleID, // Delete role_permissions entries
PermissionID: permissionID, _, err = tx.NewDelete().
} Model((*RolePermission)(nil)).
err := Insert(tx, rolePerm). Where("role_id = ?", id).
ConflictNothing("role_id", "permission_id").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "db.Insert") return errors.Wrap(err, "delete role_permissions")
} }
return nil // Delete user_roles entries
} _, err = tx.NewDelete().
Model((*UserRole)(nil)).
// RemovePermissionFromRole revokes a permission from a role Where("role_id = ?", id).
func RemovePermissionFromRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error { Exec(ctx)
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)
if err != nil { 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" "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 { type Season struct {
bun.BaseModel `bun:"table:seasons,alias:s"` bun.BaseModel `bun:"table:seasons,alias:s"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,unique,notnull"` ShortName string `bun:"short_name,unique,notnull" json:"short_name"`
StartDate time.Time `bun:"start_date,notnull"` StartDate time.Time `bun:"start_date,notnull" json:"start_date"`
EndDate bun.NullTime `bun:"end_date"` EndDate bun.NullTime `bun:"end_date" json:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date" json:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date" json:"finals_end_date"`
SlapVersion string `bun:"slap_version,notnull,default:'rebound'"` SlapVersion string `bun:"slap_version,notnull,default:'rebound'" json:"slap_version"`
Type string `bun:"type,notnull" json:"type"`
Leagues []League `bun:"m2m:season_leagues,join:Season=League"` Leagues []League `bun:"m2m:season_leagues,join:Season=League" json:"-"`
Teams []Team `bun:"m2m:team_participations,join:Season=Team"` Teams []Team `bun:"m2m:team_participations,join:Season=Team" json:"-"`
} }
// NewSeason returns a new season. It does not add it to the database // NewSeason creats a new season
func NewSeason(name, version, shortname string, start time.Time) *Season { func NewSeason(ctx context.Context, tx bun.Tx, name, version, shortname, type_ string,
start time.Time, audit *AuditMeta,
) (*Season, error) {
season := &Season{ season := &Season{
Name: name, Name: name,
ShortName: strings.ToUpper(shortname), ShortName: strings.ToUpper(shortname),
StartDate: start.Truncate(time.Hour * 24), StartDate: start.Truncate(time.Hour * 24),
SlapVersion: version, SlapVersion: version,
Type: type_,
} }
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) { 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 // 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.SlapVersion = version
s.StartDate = start.Truncate(time.Hour * 24) s.StartDate = start.Truncate(time.Hour * 24)
if !end.IsZero() { if !end.IsZero() {
@@ -66,6 +110,9 @@ func (s *Season) Update(version string, start, end, finalsStart, finalsEnd time.
if !finalsEnd.IsZero() { if !finalsEnd.IsZero() {
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24) 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) { func (s *Season) MapTeamsToLeagues(ctx context.Context, tx bun.Tx) ([]LeagueWithTeams, error) {
@@ -95,11 +142,61 @@ type LeagueWithTeams struct {
Teams []*Team Teams []*Team
} }
func (s *Season) HasLeague(leagueID int) bool { // GetStatus returns the current status of the season based on dates
for _, league := range s.Leagues { func (s *Season) GetStatus() SeasonStatus {
if league.ID == leagueID { 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 true
} }
} }
return false return false
} }
func (s *Season) GetLeague(leagueShortName string) (*League, error) {
for _, league := range s.Leagues {
if league.ShortName == leagueShortName {
return &league, nil
}
}
return nil, BadRequestNotAssociated("season", "league",
"id", "short_name", s.ID, leagueShortName)
}

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 { type Team struct {
bun.BaseModel `bun:"table:teams,alias:t"` bun.BaseModel `bun:"table:teams,alias:t"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,unique,notnull"` Name string `bun:"name,unique,notnull" json:"name"`
ShortName string `bun:"short_name,notnull,unique:short_names"` ShortName string `bun:"short_name,notnull,unique:short_names" json:"short_name"`
AltShortName string `bun:"alt_short_name,notnull,unique:short_names"` AltShortName string `bun:"alt_short_name,notnull,unique:short_names" json:"alt_short_name"`
Color string `bun:"color"` Color string `bun:"color" json:"color,omitempty"`
Seasons []Season `bun:"m2m:team_participations,join:Team=Season"` Seasons []Season `bun:"m2m:team_participations,join:Team=Season" json:"-"`
Leagues []League `bun:"m2m:team_participations,join:Team=League"` Leagues []League `bun:"m2m:team_participations,join:Team=League" json:"-"`
Players []Player `bun:"m2m:team_rosters,join:Team=Player" json:"-"`
} }
type TeamParticipation struct { func NewTeam(ctx context.Context, tx bun.Tx, name, shortName, altShortName, color string, audit *AuditMeta) (*Team, error) {
SeasonID int `bun:",pk,unique:season_team"` team := &Team{
Season *Season `bun:"rel:belongs-to,join:season_id=id"` Name: name,
LeagueID int `bun:",pk"` ShortName: shortName,
League *League `bun:"rel:belongs-to,join:league_id=id"` AltShortName: altShortName,
TeamID int `bun:",pk,unique:season_team"` Color: color,
Team *Team `bun:"rel:belongs-to,join:team_id=id"` }
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) { 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) 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 // Check if this combination of short_name and alt_short_name exists
count, err := tx.NewSelect(). count, err := tx.NewSelect().
Model((*Team)(nil)). Model((*Team)(nil)).
Where("short_name = ? AND alt_short_name = ?", shortname, altshortname). Where("short_name = ? AND alt_short_name = ?", shortName, altShortName).
Count(ctx) Count(ctx)
if err != nil { if err != nil {
return false, errors.Wrap(err, "tx.Select") return false, errors.Wrap(err, "tx.Select")
} }
return count == 0, nil 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 // WithReadTx executes a read-only transaction with automatic rollback
// Returns true if successful, false if error was thrown to client // Returns true if successful, false if error was thrown to client
func WithReadTx( func (db *DB) WithReadTx(
s *hws.Server, s *hws.Server,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
conn *bun.DB,
fn TxFunc, fn TxFunc,
) bool { ) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout) ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel() defer cancel()
ok, err := withTx(ctx, conn, fn, false) ok, err := db.withTx(ctx, fn, false)
if err != nil { if err != nil {
throw.InternalServiceError(s, w, r, "Database error", err) throw.InternalServiceError(s, w, r, "Database error", err)
} }
@@ -41,31 +40,29 @@ func WithReadTx(
// WithTxFailSilently executes a transaction with automatic rollback // WithTxFailSilently executes a transaction with automatic rollback
// Returns true if successful, false if error occured. // Returns true if successful, false if error occured.
// Does not throw any errors to the client. // Does not throw any errors to the client.
func WithTxFailSilently( func (db *DB) WithTxFailSilently(
ctx context.Context, ctx context.Context,
conn *bun.DB,
fn TxFuncSilent, fn TxFuncSilent,
) error { ) error {
fnc := func(ctx context.Context, tx bun.Tx) (bool, error) { fnc := func(ctx context.Context, tx bun.Tx) (bool, error) {
err := fn(ctx, tx) err := fn(ctx, tx)
return err == nil, err return err == nil, err
} }
_, err := withTx(ctx, conn, fnc, true) _, err := db.withTx(ctx, fnc, true)
return err return err
} }
// WithWriteTx executes a write transaction with automatic rollback on error // WithWriteTx executes a write transaction with automatic rollback on error
// Commits only if fn returns nil. Returns true if successful. // Commits only if fn returns nil. Returns true if successful.
func WithWriteTx( func (db *DB) WithWriteTx(
s *hws.Server, s *hws.Server,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
conn *bun.DB,
fn TxFunc, fn TxFunc,
) bool { ) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout) ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel() defer cancel()
ok, err := withTx(ctx, conn, fn, true) ok, err := db.withTx(ctx, fn, true)
if err != nil { if err != nil {
throw.InternalServiceError(s, w, r, "Database error", err) throw.InternalServiceError(s, w, r, "Database error", err)
} }
@@ -74,16 +71,15 @@ func WithWriteTx(
// WithNotifyTx executes a transaction with notification-based error handling // WithNotifyTx executes a transaction with notification-based error handling
// Uses notifyInternalServiceError instead of throwInternalServiceError // Uses notifyInternalServiceError instead of throwInternalServiceError
func WithNotifyTx( func (db *DB) WithNotifyTx(
s *hws.Server, s *hws.Server,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
conn *bun.DB,
fn TxFunc, fn TxFunc,
) bool { ) bool {
ctx, cancel := context.WithTimeout(r.Context(), timeout) ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel() defer cancel()
ok, err := withTx(ctx, conn, fn, true) ok, err := db.withTx(ctx, fn, true)
if err != nil { if err != nil {
notify.InternalServiceError(s, w, r, "Database error", err) notify.InternalServiceError(s, w, r, "Database error", err)
} }
@@ -91,13 +87,12 @@ func WithNotifyTx(
} }
// withTx executes a transaction with automatic rollback on error // withTx executes a transaction with automatic rollback on error
func withTx( func (db *DB) withTx(
ctx context.Context, ctx context.Context,
conn *bun.DB,
fn TxFunc, fn TxFunc,
write bool, write bool,
) (bool, error) { ) (bool, error) {
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return false, errors.Wrap(err, "conn.BeginTx") return false, errors.Wrap(err, "conn.BeginTx")
} }

View File

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

View File

@@ -12,25 +12,26 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
var CurrentUser hwsauth.ContextLoader[*User]
type User struct { type User struct {
bun.BaseModel `bun:"table:users,alias:u"` bun.BaseModel `bun:"table:users,alias:u"`
ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key) ID int `bun:"id,pk,autoincrement" json:"id"`
Username string `bun:"username,unique"` // Username (unique) Username string `bun:"username,unique" json:"username"`
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database CreatedAt int64 `bun:"created_at" json:"created_at"`
DiscordID string `bun:"discord_id,unique"` DiscordID string `bun:"discord_id,unique" json:"discord_id"`
Roles []*Role `bun:"m2m:user_roles,join:User=Role"` Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"`
Player *Player `bun:"rel:has-one,join:id=user_id"`
} }
func (u *User) GetID() int { func (u *User) GetID() int {
return u.ID return u.ID
} }
var CurrentUser hwsauth.ContextLoader[*User]
// CreateUser creates a new user with the given username and password // 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 { if discorduser == nil {
return nil, errors.New("user cannot be 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(), CreatedAt: time.Now().Unix(),
DiscordID: discorduser.ID, DiscordID: discorduser.ID,
} }
audit.u = user
err := Insert(tx, user). err := Insert(tx, user).
WithAudit(audit, nil).
Returning("id"). Returning("id").
Exec(ctx) Exec(ctx)
if err != nil { 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 // GetUserByID queries the database for a user matching the given ID
// Returns nil, nil if no user is found // Returns a BadRequestNotFound error if no user is found
func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
return GetByID[User](tx, id).Get(ctx) return GetByID[User](tx, id).Relation("Player").Get(ctx)
} }
// GetUserByUsername queries the database for a user matching the given username // GetUserByUsername queries the database for a user matching the given username
// Returns nil, nil if no user is found // Returns a BadRequestNotFound error if no user is found
func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) { func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) {
if username == "" { if username == "" {
return nil, errors.New("username not provided") return nil, errors.New("username not provided")
} }
return GetByField[User](tx, "username", username).Get(ctx) return GetByField[User](tx, "username", username).Relation("Player").Get(ctx)
} }
// GetUserByDiscordID queries the database for a user matching the given discord id // GetUserByDiscordID queries the database for a user matching the given discord id
// Returns nil, nil if no user is found // Returns a BadRequestNotFound error if no user is found
func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User, error) { func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User, error) {
if discordID == "" { if discordID == "" {
return nil, errors.New("discord_id not provided") return nil, errors.New("discord_id not provided")
} }
return GetByField[User](tx, "discord_id", discordID).Get(ctx) return GetByField[User](tx, "u.discord_id", discordID).Relation("Player").Get(ctx)
} }
// GetRoles loads all the roles for this user // GetRoles loads all the roles for this user
@@ -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) { func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) {
defaults := &PageOpts{1, 50, bun.OrderAsc, "id"} defaults := &PageOpts{1, 50, bun.OrderAsc, "id"}
return GetList[User](tx).GetPaged(ctx, pageOpts, defaults) return GetList[User](tx).Relation("Player").GetPaged(ctx, pageOpts, defaults)
} }
// GetUsersWithRoles queries the database for users with their roles preloaded // GetUsersWithRoles queries the database for users with their roles preloaded

View File

@@ -3,6 +3,7 @@ package db
import ( import (
"context" "context"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/roles" "git.haelnorr.com/h/oslstats/internal/roles"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -16,7 +17,7 @@ type UserRole struct {
} }
// AssignRole grants a role to a user // 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 { if userID <= 0 {
return errors.New("userID must be positive") 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, UserID: userID,
RoleID: roleID, RoleID: roleID,
} }
details := map[string]any{
"action": "grant",
"role_id": roleID,
}
info := &AuditInfo{
string(permissions.UsersManageRoles),
"user",
userID,
details,
}
err := Insert(tx, userRole). err := Insert(tx, userRole).
ConflictNothing("user_id", "role_id").Exec(ctx) ConflictNothing("user_id", "role_id").
WithAudit(audit, info).
Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "db.Insert") 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 // 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 { if userID <= 0 {
return errors.New("userID must be positive") 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") 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). err := DeleteItem[UserRole](tx).
Where("user_id = ?", userID). Where("user_id = ?", userID).
Where("role_id = ?", roleID). Where("role_id = ?", roleID).
WithAudit(audit, info).
Delete(ctx) Delete(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "DeleteItem") 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 { if err != nil {
return false, errors.Wrap(err, "GetByID") return false, errors.Wrap(err, "GetByID")
} }
if user == nil {
return false, nil
}
for _, role := range user.Roles { for _, role := range user.Roles {
if role.Name == roleName { if role.Name == roleName {
return true, nil return true, nil

View File

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

View File

@@ -1,41 +1,11 @@
package discord package discord
import ( import (
"runtime" "git.haelnorr.com/h/golib/ezconf"
"strings"
) )
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct {
configFunc func() (any, error)
name string
}
// PackagePath returns the path to the config package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
return func() (any, error) {
return e.configFunc()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return strings.ToLower(e.name)
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return e.name
}
// NewEZConfIntegration creates a new EZConf integration helper // NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration { func NewEZConfIntegration() *ezconf.Integration {
return EZConfIntegration{name: "Discord", configFunc: ConfigFromEnv} 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 // Do executes an HTTP request with automatic rate limit handling
// It will wait if rate limits are about to be exceeded and retry once if a 429 is received // It will wait if rate limits are about to be exceeded and retry once if a 429 is received
func (c *APIClient) Do(req *http.Request) (*http.Response, error) { func (api *APIClient) Do(req *http.Request) (*http.Response, error) {
if req == nil { if req == nil {
return nil, errors.New("request cannot be nil") return nil, errors.New("request cannot be nil")
} }
// Step 1: Check if we need to wait before making request // Step 1: Check if we need to wait before making request
bucket := c.getBucketFromRequest(req) bucket := api.getBucketFromRequest(req)
if err := c.waitIfNeeded(bucket); err != nil { if err := api.waitIfNeeded(bucket); err != nil {
return nil, err return nil, err
} }
// Step 2: Execute request // Step 2: Execute request
resp, err := c.client.Do(req) resp, err := api.client.Do(req)
if err != nil { if err != nil {
// Check if it's a network timeout // Check if it's a network timeout
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
@@ -41,17 +41,17 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
} }
// Step 3: Update rate limit state from response headers // Step 3: Update rate limit state from response headers
c.updateRateLimit(resp.Header) api.updateRateLimit(resp.Header)
// Step 4: Handle 429 (rate limited) // Step 4: Handle 429 (rate limited)
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() // Close original response resp.Body.Close() // Close original response
retryAfter := c.parseRetryAfter(resp.Header) retryAfter := api.parseRetryAfter(resp.Header)
// No Retry-After header, can't retry safely // No Retry-After header, can't retry safely
if retryAfter == 0 { if retryAfter == 0 {
c.logger.Warn(). api.logger.Warn().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -61,7 +61,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// Retry-After exceeds 30 second cap // Retry-After exceeds 30 second cap
if retryAfter > 30*time.Second { if retryAfter > 30*time.Second {
c.logger.Warn(). api.logger.Warn().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
} }
// Wait and retry // Wait and retry
c.logger.Warn(). api.logger.Warn().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
time.Sleep(retryAfter) time.Sleep(retryAfter)
// Retry the request // Retry the request
resp, err = c.client.Do(req) resp, err = api.client.Do(req)
if err != nil { if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return nil, errors.Wrap(err, "retry request timed out") return nil, errors.Wrap(err, "retry request timed out")
@@ -93,12 +93,12 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
} }
// Update rate limit again after retry // Update rate limit again after retry
c.updateRateLimit(resp.Header) api.updateRateLimit(resp.Header)
// If STILL rate limited after retry, return error // If STILL rate limited after retry, return error
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() resp.Body.Close()
c.logger.Error(). api.logger.Error().
Str("bucket", bucket). Str("bucket", bucket).
Str("method", req.Method). Str("method", req.Method).
Str("path", req.URL.Path). Str("path", req.URL.Path).
@@ -115,15 +115,15 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
// getBucketFromRequest extracts or generates bucket ID from request // getBucketFromRequest extracts or generates bucket ID from request
// For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers // For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers
func (c *APIClient) getBucketFromRequest(req *http.Request) string { func (api *APIClient) getBucketFromRequest(req *http.Request) string {
return req.Method + ":" + req.URL.Path return req.Method + ":" + req.URL.Path
} }
// waitIfNeeded checks if we need to delay before request to avoid hitting rate limits // waitIfNeeded checks if we need to delay before request to avoid hitting rate limits
func (c *APIClient) waitIfNeeded(bucket string) error { func (api *APIClient) waitIfNeeded(bucket string) error {
c.mu.RLock() api.mu.RLock()
state, exists := c.buckets[bucket] state, exists := api.buckets[bucket]
c.mu.RUnlock() api.mu.RUnlock()
if !exists { if !exists {
return nil // No state yet, proceed return nil // No state yet, proceed
@@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
waitDuration += 100 * time.Millisecond waitDuration += 100 * time.Millisecond
if waitDuration > 0 { if waitDuration > 0 {
c.logger.Debug(). api.logger.Debug().
Str("bucket", bucket). Str("bucket", bucket).
Dur("wait_duration", waitDuration). Dur("wait_duration", waitDuration).
Msg("Proactively waiting for rate limit reset") Msg("Proactively waiting for rate limit reset")
@@ -150,16 +150,16 @@ func (c *APIClient) waitIfNeeded(bucket string) error {
} }
// updateRateLimit parses response headers and updates bucket state // updateRateLimit parses response headers and updates bucket state
func (c *APIClient) updateRateLimit(headers http.Header) { func (api *APIClient) updateRateLimit(headers http.Header) {
bucket := headers.Get("X-RateLimit-Bucket") bucket := headers.Get("X-RateLimit-Bucket")
if bucket == "" { if bucket == "" {
return // No bucket info, can't track return // No bucket info, can't track
} }
// Parse headers // Parse headers
limit := c.parseInt(headers.Get("X-RateLimit-Limit")) limit := api.parseInt(headers.Get("X-RateLimit-Limit"))
remaining := c.parseInt(headers.Get("X-RateLimit-Remaining")) remaining := api.parseInt(headers.Get("X-RateLimit-Remaining"))
resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After")) resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After"))
state := &RateLimitState{ state := &RateLimitState{
Bucket: bucket, Bucket: bucket,
@@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))), Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))),
} }
c.mu.Lock() api.mu.Lock()
c.buckets[bucket] = state api.buckets[bucket] = state
c.mu.Unlock() api.mu.Unlock()
// Log rate limit state for debugging // Log rate limit state for debugging
c.logger.Debug(). api.logger.Debug().
Str("bucket", bucket). Str("bucket", bucket).
Int("remaining", remaining). Int("remaining", remaining).
Int("limit", limit). Int("limit", limit).
@@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) {
} }
// parseRetryAfter extracts retry delay from Retry-After header // parseRetryAfter extracts retry delay from Retry-After header
func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration { func (api *APIClient) parseRetryAfter(headers http.Header) time.Duration {
retryAfter := headers.Get("Retry-After") retryAfter := headers.Get("Retry-After")
if retryAfter == "" { if retryAfter == "" {
return 0 return 0
} }
// Discord returns seconds as float // Discord returns seconds as float
seconds := c.parseFloat(retryAfter) seconds := api.parseFloat(retryAfter)
if seconds <= 0 { if seconds <= 0 {
return 0 return 0
} }
@@ -198,7 +198,7 @@ func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration {
} }
// parseInt parses an integer from a header value, returns 0 on error // parseInt parses an integer from a header value, returns 0 on error
func (c *APIClient) parseInt(s string) int { func (api *APIClient) parseInt(s string) int {
if s == "" { if s == "" {
return 0 return 0
} }
@@ -207,7 +207,7 @@ func (c *APIClient) parseInt(s string) int {
} }
// parseFloat parses a float from a header value, returns 0 on error // parseFloat parses a float from a header value, returns 0 on error
func (c *APIClient) parseFloat(s string) float64 { func (api *APIClient) parseFloat(s string) float64 {
if s == "" { if s == "" {
return 0 return 0
} }

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 var embeddedFiles embed.FS
// GetEmbeddedFS gets the embedded files // GetEmbeddedFS gets the embedded files
func GetEmbeddedFS() (fs.FS, error) { func GetEmbeddedFS() (*fs.FS, error) {
subFS, err := fs.Sub(embeddedFiles, "web") subFS, err := fs.Sub(embeddedFiles, "web")
if err != nil { if err != nil {
return nil, errors.Wrap(err, "fs.Sub") 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-weight: 700;
font-style: italic; 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 // Initialize flatpickr for all date inputs
document.addEventListener("DOMContentLoaded", function () { function initFlatpickr() {
// Update active nav item after HTMX navigation document.querySelectorAll(".flatpickr-date").forEach(function (input) {
document.body.addEventListener("htmx:afterSwap", function (event) { if (!input._flatpickr) {
if (event.detail.target.id === "admin-content") { flatpickr(input, {
// Get the current URL path dateFormat: "d/m/Y",
const path = window.location.pathname; allowInput: true,
const section = path.split("/").pop() || "users"; });
}
});
}
// Update active state on nav items // Submit the audit filter form with specific page/perPage/order params
document.querySelectorAll("nav a").forEach(function (link) { function submitAuditFilter(page, perPage, order, orderBy) {
const href = link.getAttribute("href"); const form = document.getElementById("audit-filters-form");
if (href && href.includes("/" + section)) { if (!form) return;
link.classList.remove("text-subtext0", "hover:bg-surface1", "hover:text-text");
link.classList.add("bg-blue", "text-mantle", "font-semibold"); // Create hidden inputs for pagination/sorting if they don't exist
} else { let pageInput = form.querySelector('input[name="page"]');
link.classList.remove("bg-blue", "text-mantle", "font-semibold"); if (!pageInput) {
link.classList.add("text-subtext0", "hover:bg-surface1", "hover:text-text"); 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(); 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) { setPerPage(n) {
this.perPage = n; this.perPage = n;
this.page = 1; // Reset to first page when changing per page 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/throw"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
adminview "git.haelnorr.com/h/oslstats/internal/view/adminview" adminview "git.haelnorr.com/h/oslstats/internal/view/adminview"
"git.haelnorr.com/h/timefmt"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
// AdminAuditLogsPage renders the full admin dashboard page with audit logs section // AdminAuditLogsPage renders the full admin dashboard page with audit logs section (GET request)
func AdminAuditLogsPage(s *hws.Server, conn *bun.DB) http.Handler { func AdminAuditLogsPage(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 logs *db.List[db.AuditLog]
var users []*db.User var users []*db.User
var actions []string var actions []string
var resourceTypes []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 var err error
// Get page options from query
pageOpts := pageOptsFromQuery(s, w, r)
if pageOpts == nil {
return false, nil
}
// Get filters from query // Get filters from query
filters, ok := getAuditFiltersFromQuery(s, w, r) filters, ok := getAuditFiltersFromQuery(s, w, r)
if !ok { 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) // AdminAuditLogsList shows the full audit logs list with filters (POST request for HTMX)
func AdminAuditLogsList(s *hws.Server, conn *bun.DB) http.Handler { func AdminAuditLogsList(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 logs *db.List[db.AuditLog]
var users []*db.User var users []*db.User
var actions []string var actions []string
var resourceTypes []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 var err error
// Get page options from form // Get filters from form
pageOpts := pageOptsFromForm(s, w, r) filters, ok := getAuditFiltersFromForm(s, w, r)
if pageOpts == nil { if !ok {
return false, nil return false, nil
} }
// No filters for initial section load
filters := db.NewAuditLogFilter()
// Get audit logs // Get audit logs
logs, err = db.GetAuditLogs(ctx, tx, pageOpts, filters) logs, err = db.GetAuditLogs(ctx, tx, pageOpts, filters)
if err != nil { 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 // AdminAuditLogsFilter returns only the results container (table + pagination) for HTMX updates
func AdminAuditLogsFilter(s *hws.Server, conn *bun.DB) http.Handler { func AdminAuditLogsFilter(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 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 var err error
// Get page options from form
pageOpts := pageOptsFromForm(s, w, r)
if pageOpts == nil {
return false, nil
}
// Get filters from form // Get filters from form
filters, ok := getAuditFiltersFromForm(s, w, r) filters, ok := getAuditFiltersFromForm(s, w, r)
if !ok { if !ok {
@@ -157,12 +158,13 @@ func AdminAuditLogsFilter(s *hws.Server, conn *bun.DB) http.Handler {
return return
} }
// Return only the results container, not the full page with filters
renderSafely(adminview.AuditLogsResults(logs), s, r, w) renderSafely(adminview.AuditLogsResults(logs), s, r, w)
}) })
} }
// AdminAuditLogDetail shows details for a single audit log entry // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get ID from path // Get ID from path
idStr := r.PathValue("id") idStr := r.PathValue("id")
@@ -179,16 +181,16 @@ func AdminAuditLogDetail(s *hws.Server, conn *bun.DB) http.Handler {
var log *db.AuditLog 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 var err error
log, err = db.GetAuditLogByID(ctx, tx, id) log, err = db.GetAuditLogByID(ctx, tx, id)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetAuditLogByID") return false, errors.Wrap(err, "db.GetAuditLogByID")
} }
if log == nil {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return true, nil return true, nil
}); !ok { }); !ok {
return return
@@ -201,7 +203,8 @@ func AdminAuditLogDetail(s *hws.Server, conn *bun.DB) http.Handler {
// getAuditFiltersFromQuery extracts audit log filters from query string // getAuditFiltersFromQuery extracts audit log filters from query string
func getAuditFiltersFromQuery(s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) { func getAuditFiltersFromQuery(s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) {
g := validation.NewQueryGetter(r) 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 // 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) { func buildAuditFilters(g validation.Getter, s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) {
filters := db.NewAuditLogFilter() filters := db.NewAuditLogFilter()
// User ID filter (optional) userIDs := g.IntList("user_id").Values()
userID := g.Int("user_id").Optional().Min(1).Value 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) { if !g.ValidateAndError(s, w, r) {
return nil, false return nil, false
} }
// Apply filters if len(userIDs) > 0 {
if userID > 0 { filters.UserIDs(userIDs)
filters.UserID(userID) }
if len(actions) > 0 {
filters.Actions(actions)
}
if len(resourceTypes) > 0 {
filters.ResourceTypes(resourceTypes)
}
if len(results) > 0 {
filters.Results(results)
} }
if action != "" { if !startDate.IsZero() {
filters.Action(action) filters.DateRange(startDate.Unix(), 0)
} }
if !endDate.IsZero() {
if resourceType != "" { endOfDay := endDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
filters.ResourceType(resourceType) filters.DateRange(0, endOfDay.Unix())
}
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())
}
} }
return filters, true return filters, true

View File

@@ -12,10 +12,10 @@ import (
) )
// AdminDashboard renders the full admin dashboard page (defaults to users section) // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var users *db.List[db.User] 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 var err error
users, err = db.GetUsersWithRoles(ctx, tx, nil) users, err = db.GetUsersWithRoles(ctx, tx, nil)
if err != 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 package handlers
import ( import (
"context"
"net/http" "net/http"
"sort"
"strconv"
"time"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/roles"
"git.haelnorr.com/h/oslstats/internal/validation"
adminview "git.haelnorr.com/h/oslstats/internal/view/adminview" adminview "git.haelnorr.com/h/oslstats/internal/view/adminview"
"github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
// AdminRolesPage renders the full admin dashboard page with roles section // AdminRoles renders the full admin dashboard page with roles section
func AdminRolesPage(s *hws.Server, conn *bun.DB) http.Handler { func AdminRoles(s *hws.Server, conn *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Load roles from database pageOpts, ok := db.GetPageOpts(s, w, r)
renderSafely(adminview.RolesPage(), s, r, w) 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) // AdminRoleCreateForm shows the create role form modal
func AdminRolesList(s *hws.Server, conn *bun.DB) http.Handler { func AdminRoleCreateForm(s *hws.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Load roles from database renderSafely(adminview.RoleCreateForm(), s, r, w)
renderSafely(adminview.RolesList(), 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 // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var users *db.List[db.User] pageOpts, ok := db.GetPageOpts(s, w, r)
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { if !ok {
var err error
users, err = db.GetUsersWithRoles(ctx, tx, nil)
if err != nil {
return false, errors.Wrap(err, "db.GetUsersWithRoles")
}
return true, nil
}); !ok {
return 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] 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 var err error
// Get users with their roles users, err = db.GetUsersWithRoles(ctx, tx, pageOpts)
users, err = db.GetUsersWithRoles(ctx, tx, nil)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.GetUsersWithRoles") return false, errors.Wrap(err, "db.GetUsersWithRoles")
} }
@@ -44,6 +30,10 @@ func AdminUsersList(s *hws.Server, conn *bun.DB) http.Handler {
}); !ok { }); !ok {
return 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 { if err != nil {
return errors.Wrap(err, "db.GetRoleByName") return errors.Wrap(err, "db.GetRoleByName")
} }
if adminRole == nil {
return errors.New("admin role not found in database")
}
// Grant admin role // Grant admin role
err = db.AssignRole(ctx, tx, user.ID, adminRole.ID) err = db.AssignRole(ctx, tx, user.ID, adminRole.ID, nil)
if err != nil { if err != nil {
return errors.Wrap(err, "db.AssignRole") return errors.Wrap(err, "db.AssignRole")
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"git.haelnorr.com/h/golib/hwsauth" "git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -16,18 +17,13 @@ import (
func Logout( func Logout(
s *hws.Server, s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx], auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB, conn *db.DB,
discordAPI *discord.APIClient, discordAPI *discord.APIClient,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
user := db.CurrentUser(r.Context()) user := db.CurrentUser(r.Context())
if user == nil { if ok := conn.WithWriteTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
// 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) {
token, err := user.DeleteDiscordTokens(ctx, tx) token, err := user.DeleteDiscordTokens(ctx, tx)
if err != nil { if err != nil {
return false, errors.Wrap(err, "user.DeleteDiscordTokens") return false, errors.Wrap(err, "user.DeleteDiscordTokens")
@@ -48,7 +44,7 @@ func Logout(
}); !ok { }); !ok {
return 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/config"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/rbac"
"git.haelnorr.com/h/oslstats/internal/respond"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
"git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/throw"
authview "git.haelnorr.com/h/oslstats/internal/view/authview" authview "git.haelnorr.com/h/oslstats/internal/view/authview"
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
) )
func Register( func Register(
s *hws.Server, s *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx], auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB, conn *db.DB,
slapAPI *slapshotapi.SlapAPI,
cfg *config.Config, cfg *config.Config,
store *store.Store, store *store.Store,
) http.Handler { ) http.Handler {
@@ -55,7 +60,8 @@ func Register(
username := r.FormValue("username") username := r.FormValue("username")
unique := false unique := false
var user *db.User 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) unique, err = db.IsUnique(ctx, tx, (*db.User)(nil), "username", username)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.IsUsernameUnique") return false, errors.Wrap(err, "db.IsUsernameUnique")
@@ -63,26 +69,20 @@ func Register(
if !unique { if !unique {
return true, nil 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 { if err != nil {
return false, errors.Wrap(err, "db.CreateUser") return false, errors.Wrap(err, "registerUser")
} }
err = user.UpdateDiscordToken(ctx, tx, details.Token) err = connectSlapID(ctx, tx, user, details.Token, slapAPI, audit)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.UpdateDiscordToken") return false, errors.Wrap(err, "connectSlapID")
}
if shouldGrantAdmin(user, cfg.RBAC) {
err := ensureUserHasAdminRole(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "ensureUserHasAdminRole")
}
} }
return true, nil return true, nil
}); !ok { }); !ok {
return return
} }
if !unique { if !unique {
w.WriteHeader(http.StatusConflict) respond.Conflict(w, errors.New("username is taken"))
} else { } else {
err = auth.Login(w, r, user, true) err = auth.Login(w, r, user, true)
if err != nil { if err != nil {
@@ -90,8 +90,67 @@ func Register(
return return
} }
pageFrom := cookies.CheckPageFrom(w, r) pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom) respond.HXRedirect(w, "%s", pageFrom)
} }
}, },
) )
} }
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 ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
@@ -14,22 +15,23 @@ import (
func SeasonPage( func SeasonPage(
s *hws.Server, s *hws.Server,
conn *bun.DB, conn *db.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name") seasonStr := r.PathValue("season_short_name")
var season *db.Season var season *db.Season
var leaguesWithTeams []db.LeagueWithTeams 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 var err error
season, err = db.GetSeason(ctx, tx, seasonStr) season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil { if err != nil {
if db.IsBadRequest(err) {
throw.NotFound(s, w, r, r.URL.Path)
return false, nil
}
return false, errors.Wrap(err, "db.GetSeason") return false, errors.Wrap(err, "db.GetSeason")
} }
if season == nil {
return true, nil
}
leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx) leaguesWithTeams, err = season.MapTeamsToLeagues(ctx, tx)
if err != nil { if err != nil {
@@ -40,9 +42,8 @@ func SeasonPage(
}); !ok { }); !ok {
return return
} }
if season == nil { if season.Type == db.SeasonTypeDraft.String() {
throw.NotFound(s, w, r, r.URL.Path) http.Redirect(w, r, fmt.Sprintf("/seasons/%s/leagues/%s", season.ShortName, "Draft"), http.StatusTemporaryRedirect)
return
} }
renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w) renderSafely(seasonsview.DetailPage(season, leaguesWithTeams), s, r, w)
}) })

View File

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

View File

@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"git.haelnorr.com/h/golib/hws" "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/db"
"git.haelnorr.com/h/oslstats/internal/notify" "git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/validation"
@@ -16,12 +15,11 @@ import (
func SeasonLeagueAddTeam( func SeasonLeagueAddTeam(
s *hws.Server, s *hws.Server,
conn *bun.DB, conn *db.DB,
audit *auditlog.Logger,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name") seasonShortName := r.PathValue("season_short_name")
leagueStr := r.PathValue("league_short_name") leagueShortName := r.PathValue("league_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r) getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok { if !ok {
@@ -36,80 +34,23 @@ func SeasonLeagueAddTeam(
var league *db.League var league *db.League
var team *db.Team 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 var err error
team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonShortName, leagueShortName, teamID, db.NewAuditFromRequest(r))
// Get season
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil { 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 return true, nil
}); !ok { }); !ok {
return return
} }
// Redirect to refresh the page // 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) w.WriteHeader(http.StatusOK)
notify.Success(s, w, r, "Team Added", fmt.Sprintf( notify.Success(s, w, r, "Team Added", fmt.Sprintf(
"Successfully added '%s' to the league.", "Successfully added '%s' to the league.",

View File

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

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