Compare commits
33 Commits
51428e061d
...
ac74f3197b
| Author | SHA1 | Date | |
|---|---|---|---|
| ac74f3197b | |||
| 5faaf5e959 | |||
| c49d58f9b7 | |||
| 02cb57eb7e | |||
| 9b2eba76a7 | |||
| d0c1b53d87 | |||
| 3c866551a4 | |||
| 4185ab58e2 | |||
| 1c93a707ab | |||
| 680ba3fe50 | |||
| 6439bf782b | |||
| 971960d0cb | |||
| 9e3355deb6 | |||
| 7ea21c63e4 | |||
| c3d8e6c675 | |||
| 71373c4747 | |||
| 667c9f04a7 | |||
| 4b81ac12cf | |||
| 52a168f1aa | |||
| 7d5949af1e | |||
| 9db855f45b | |||
| 2db24c3f77 | |||
| 42282d05b1 | |||
| 9362448f22 | |||
| f8090aa0cc | |||
| bb3bed3e89 | |||
| 3b430d39e2 | |||
| 0c5a88c309 | |||
| ef8c022e60 | |||
| 61890ae20b | |||
| 4a2396bca8 | |||
| 0fc3bb0c94 | |||
| 55f79176cc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
|
.test.env
|
||||||
*.db*
|
*.db*
|
||||||
.logs/
|
.logs/
|
||||||
server.log
|
server.log
|
||||||
|
|||||||
124
.test.env
124
.test.env
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
9
go.mod
@@ -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
18
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
25
internal/contexts/preview_role.go
Normal file
25
internal/contexts/preview_role.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
internal/db/auditlogger.go
Normal file
84
internal/db/auditlogger.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
31
internal/db/errors.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
380
internal/db/fixture.go
Normal 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
|
||||||
|
}
|
||||||
587
internal/db/fixture_result.go
Normal file
587
internal/db/fixture_result.go
Normal 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
|
||||||
|
}
|
||||||
426
internal/db/fixture_schedule.go
Normal file
426
internal/db/fixture_schedule.go
Normal 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
342
internal/db/freeagent.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
44
internal/db/migrations/20260213162216_missing_permissions.go
Normal file
44
internal/db/migrations/20260213162216_missing_permissions.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
52
internal/db/migrations/20260215093841_add_fixtures.go
Normal file
52
internal/db/migrations/20260215093841_add_fixtures.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
37
internal/db/migrations/20260216211155_players.go
Normal file
37
internal/db/migrations/20260216211155_players.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
63
internal/db/migrations/20260218185128_add_type_to_seasons.go
Normal file
63
internal/db/migrations/20260218185128_add_type_to_seasons.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
31
internal/db/migrations/20260219203524_player_names.go
Normal file
31
internal/db/migrations/20260219203524_player_names.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
30
internal/db/migrations/20260220174806_team_rosters.go
Normal file
30
internal/db/migrations/20260220174806_team_rosters.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
91
internal/db/migrations/20260221140000_fixture_results.go
Normal file
91
internal/db/migrations/20260221140000_fixture_results.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
91
internal/db/migrations/20260222140000_free_agents.go
Normal file
91
internal/db/migrations/20260222140000_free_agents.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
123
internal/db/player.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
99
internal/db/rolepermission.go
Normal file
99
internal/db/rolepermission.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
143
internal/db/seasonleague.go
Normal 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
61
internal/db/setup.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
56
internal/db/teamparticipation.go
Normal file
56
internal/db/teamparticipation.go
Normal 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
247
internal/db/teamroster.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
20
internal/discord/steamid.go
Normal file
20
internal/discord/steamid.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
151
internal/embedfs/web/css/flatpickr-catppuccin.css
Normal file
151
internal/embedfs/web/css/flatpickr-catppuccin.css
Normal 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 */
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
84
internal/embedfs/web/js/localtime.js
Normal file
84
internal/embedfs/web/js/localtime.js
Normal 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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
|||||||
50
internal/embedfs/web/js/tabs.js
Normal file
50
internal/embedfs/web/js/tabs.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
795
internal/embedfs/web/vendored/flatpickr-dark@4.6.13.min.css
vendored
Normal file
795
internal/embedfs/web/vendored/flatpickr-dark@4.6.13.min.css
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/embedfs/web/vendored/flatpickr@4.6.13.min.css
vendored
Normal file
13
internal/embedfs/web/vendored/flatpickr@4.6.13.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
internal/embedfs/web/vendored/flatpickr@4.6.13.min.js
vendored
Normal file
2
internal/embedfs/web/vendored/flatpickr@4.6.13.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
internal/embedfs/web/vendored/sortablejs@1.15.6.min.js
vendored
Normal file
2
internal/embedfs/web/vendored/sortablejs@1.15.6.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
79
internal/handlers/admin_preview_role.go
Normal file
79
internal/handlers/admin_preview_role.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
501
internal/handlers/fixture_detail.go
Normal file
501
internal/handlers/fixture_detail.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
471
internal/handlers/fixture_result.go
Normal file
471
internal/handlers/fixture_result.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
187
internal/handlers/fixture_result_validation.go
Normal file
187
internal/handlers/fixture_result_validation.go
Normal 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)
|
||||||
|
}
|
||||||
190
internal/handlers/fixtures.go
Normal file
190
internal/handlers/fixtures.go
Normal 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
|
||||||
|
}
|
||||||
356
internal/handlers/free_agents.go
Normal file
356
internal/handlers/free_agents.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "/")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user