Compare commits

..

3 Commits

Author SHA1 Message Date
a4b4f4f4af refactored db code 2026-02-09 22:14:38 +11:00
b89ee75ca7 fixed some migration issues and added generics for update and insert 2026-02-09 21:58:50 +11:00
bf3e526f1e added season edits 2026-02-09 20:35:04 +11:00
31 changed files with 1023 additions and 264 deletions

View File

@@ -12,6 +12,7 @@ This document provides guidelines for AI coding agents and developers working on
## Build, Test, and Development Commands ## Build, Test, and Development Commands
### Building ### Building
NEVER BUILD MANUALLY
```bash ```bash
# Full production build (tailwind → templ → go generate → go build) # Full production build (tailwind → templ → go generate → go build)
make build make build

View File

@@ -23,11 +23,12 @@ func setupBun(cfg *config.Config) (conn *bun.DB, close func() error) {
sqldb.SetConnMaxIdleTime(5 * time.Minute) sqldb.SetConnMaxIdleTime(5 * time.Minute)
conn = bun.NewDB(sqldb, pgdialect.New()) conn = bun.NewDB(sqldb, pgdialect.New())
registerDBModels(conn)
close = sqldb.Close close = sqldb.Close
return conn, close return conn, close
} }
func registerDBModels(conn *bun.DB) { func registerDBModels(conn *bun.DB) []any {
models := []any{ models := []any{
(*db.RolePermission)(nil), (*db.RolePermission)(nil),
(*db.UserRole)(nil), (*db.UserRole)(nil),
@@ -39,4 +40,5 @@ func registerDBModels(conn *bun.DB) {
(*db.AuditLog)(nil), (*db.AuditLog)(nil),
} }
conn.RegisterModel(models...) conn.RegisterModel(models...)
return models
} }

View File

@@ -15,7 +15,6 @@ import (
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations" "git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
"git.haelnorr.com/h/oslstats/internal/backup" "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"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
"github.com/uptrace/bun/migrate" "github.com/uptrace/bun/migrate"
@@ -349,10 +348,7 @@ func resetDatabase(ctx context.Context, cfg *config.Config) error {
conn, close := setupBun(cfg) conn, close := setupBun(cfg)
defer func() { _ = close() }() defer func() { _ = close() }()
models := []any{ models := registerDBModels(conn)
(*db.User)(nil),
(*db.DiscordToken)(nil),
}
for _, model := range models { for _, model := range models {
if err := conn.ResetModel(ctx, model); err != nil { if err := conn.ResetModel(ctx, model); err != nil {

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -141,78 +142,7 @@ func init() {
return err return err
} }
// Seed system roles err = seedSystemRBAC(ctx, dbConn)
now := time.Now().Unix()
adminRole := &db.Role{
Name: "admin",
DisplayName: "Administrator",
Description: "Full system access with all permissions",
IsSystem: true,
CreatedAt: now,
}
_, err = dbConn.NewInsert().
Model(adminRole).
Returning("id").
Exec(ctx)
if err != nil {
return err
}
userRole := &db.Role{
Name: "user",
DisplayName: "User",
Description: "Standard user with basic permissions",
IsSystem: true,
CreatedAt: now,
}
_, err = dbConn.NewInsert().
Model(userRole).
Exec(ctx)
if err != nil {
return err
}
// Seed system permissions
permissionsData := []*db.Permission{
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", 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().
Model(&permissionsData).
Exec(ctx)
if err != nil {
return err
}
// Grant wildcard permission to admin role using Bun
// First, get the IDs
var wildcardPerm db.Permission
err = dbConn.NewSelect().
Model(&wildcardPerm).
Where("name = ?", "*").
Scan(ctx)
if err != nil {
return err
}
// Insert role_permission mapping
adminRolePerms := &db.RolePermission{
RoleID: adminRole.ID,
PermissionID: wildcardPerm.ID,
}
_, err = dbConn.NewInsert().
Model(adminRolePerms).
On("CONFLICT (role_id, permission_id) DO NOTHING").
Exec(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -242,3 +172,82 @@ func init() {
}, },
) )
} }
func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
// Seed system roles
now := time.Now().Unix()
adminRole := &db.Role{
Name: "admin",
DisplayName: "Administrator",
Description: "Full system access with all permissions",
IsSystem: true,
CreatedAt: now,
}
_, err := dbConn.NewInsert().
Model(adminRole).
Returning("id").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "dbConn.NewInsert")
}
userRole := &db.Role{
Name: "user",
DisplayName: "User",
Description: "Standard user with basic permissions",
IsSystem: true,
CreatedAt: now,
}
_, err = dbConn.NewInsert().
Model(userRole).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "dbConn.NewInsert")
}
// Seed system permissions
permissionsData := []*db.Permission{
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", 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().
Model(&permissionsData).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "dbConn.NewInsert")
}
// Grant wildcard permission to admin role using Bun
// First, get the IDs
var wildcardPerm db.Permission
err = dbConn.NewSelect().
Model(&wildcardPerm).
Where("name = ?", "*").
Scan(ctx)
if err != nil {
return err
}
// Insert role_permission mapping
adminRolePerms := &db.RolePermission{
RoleID: adminRole.ID,
PermissionID: wildcardPerm.ID,
}
_, err = dbConn.NewInsert().
Model(adminRolePerms).
On("CONFLICT (role_id, permission_id) DO NOTHING").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "dbConn.NewInsert")
}
return nil
}

View File

@@ -13,6 +13,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/handlers" "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/rbac"
"git.haelnorr.com/h/oslstats/internal/store" "git.haelnorr.com/h/oslstats/internal/store"
) )
@@ -83,18 +84,28 @@ func addRoutes(
{ {
Path: "/seasons/new", Path: "/seasons/new",
Method: hws.MethodGET, Method: hws.MethodGET,
Handler: handlers.NewSeason(s, conn), Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeason(s, conn)),
}, },
{ {
Path: "/seasons/new", Path: "/seasons/new",
Method: hws.MethodPOST, Method: hws.MethodPOST,
Handler: handlers.NewSeasonSubmit(s, conn), Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeasonSubmit(s, conn, audit)),
}, },
{ {
Path: "/seasons/{season_short_name}", Path: "/seasons/{season_short_name}",
Method: hws.MethodGET, Method: hws.MethodGET,
Handler: handlers.SeasonPage(s, conn), 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)),
},
} }
htmxRoutes := []hws.Route{ htmxRoutes := []hws.Route{

View File

@@ -26,7 +26,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
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) bun, closedb := setupBun(cfg)
registerDBModels(bun) // registerDBModels(bun)
// Setup embedded files // Setup embedded files
logger.Debug().Msg("Getting embedded files") logger.Debug().Msg("Getting embedded files")

2
go.sum
View File

@@ -8,8 +8,6 @@ git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4V
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c= git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM= git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
git.haelnorr.com/h/golib/hwsauth v0.6.0 h1:qaRsgfcD7M6xCasfXwP6Ww9RM4TwDqYMFK2YtO6nt6c=
git.haelnorr.com/h/golib/hwsauth v0.6.0/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0= git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0=
git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA= git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=

View File

@@ -156,3 +156,32 @@ func (l *Logger) CleanupOldLogs(ctx context.Context, daysToKeep int) (int, error
} }
return count, nil 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,
)
}
}

146
internal/db/audit.go Normal file
View File

@@ -0,0 +1,146 @@
package db
import (
"context"
"net/http"
"reflect"
"strings"
"github.com/uptrace/bun"
)
// AuditInfo contains metadata for audit logging
type AuditInfo struct {
Action string // e.g., "seasons.create", "users.update"
ResourceType string // e.g., "season", "user"
ResourceID any // Primary key value (int, string, etc.)
Details map[string]any // Changed fields or additional metadata
}
// AuditCallback is called after successful database operations to log changes
type AuditCallback func(ctx context.Context, tx bun.Tx, info *AuditInfo, r *http.Request) error
// extractTableName gets the bun table name from a model type using reflection
// Example: Season with `bun:"table:seasons,alias:s"` returns "seasons"
func extractTableName[T any]() string {
var model T
t := reflect.TypeOf(model)
// Handle pointer types
if t.Kind() == reflect.Ptr {
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"
parts := strings.Split(bunTag, ",")
for _, part := range parts {
if strings.HasPrefix(part, "table:") {
return strings.TrimPrefix(part, "table:")
}
}
}
}
}
// Fallback: use struct name in lowercase + "s"
return strings.ToLower(t.Name()) + "s"
}
// extractResourceType converts a table name to singular resource type
// Example: "seasons" -> "season", "users" -> "user"
func extractResourceType(tableName string) string {
// Simple singularization: remove trailing 's'
if strings.HasSuffix(tableName, "s") && len(tableName) > 1 {
return tableName[:len(tableName)-1]
}
return tableName
}
// buildAction creates a permission-style action string
// Example: ("season", "create") -> "seasons.create"
func buildAction(resourceType, operation string) string {
// Pluralize resource type (simple: add 's')
plural := resourceType
if !strings.HasSuffix(plural, "s") {
plural = plural + "s"
}
return plural + "." + operation
}
// extractPrimaryKey uses reflection to find and return the primary key value from a model
// Returns nil if no primary key is found
func extractPrimaryKey[T any](model *T) any {
if model == nil {
return nil
}
v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
bunTag := field.Tag.Get("bun")
if bunTag != "" && strings.Contains(bunTag, "pk") {
// Found primary key field
fieldValue := v.Field(i)
if fieldValue.IsValid() && fieldValue.CanInterface() {
return fieldValue.Interface()
}
}
}
return nil
}
// extractChangedFields builds a map of field names to their new values
// Only includes fields specified in the columns list
func extractChangedFields[T any](model *T, columns []string) map[string]any {
if model == nil || len(columns) == 0 {
return nil
}
result := make(map[string]any)
v := reflect.ValueOf(model)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
// Build map of bun column names to field names
columnToField := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
bunTag := field.Tag.Get("bun")
if bunTag != "" {
// Parse bun tag to get column name (first part before comma)
parts := strings.Split(bunTag, ",")
if len(parts) > 0 && parts[0] != "" {
columnToField[parts[0]] = i
}
}
}
// Extract values for requested columns
for _, col := range columns {
if fieldIdx, ok := columnToField[col]; ok {
fieldValue := v.Field(fieldIdx)
if fieldValue.IsValid() && fieldValue.CanInterface() {
result[col] = fieldValue.Interface()
}
}
}
return result
}
// Note: We don't need getTxFromQuery since we store the tx directly in our helper structs

View File

@@ -32,14 +32,10 @@ func CreateAuditLog(ctx context.Context, tx bun.Tx, log *AuditLog) error {
if log == nil { if log == nil {
return errors.New("log cannot be nil") return errors.New("log cannot be nil")
} }
err := Insert(tx, log).Exec(ctx)
_, err := tx.NewInsert().
Model(log).
Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewInsert") return errors.Wrap(err, "db.Insert")
} }
return nil return nil
} }

View File

@@ -3,13 +3,18 @@ package db
import ( import (
"context" "context"
"database/sql" "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 {
q *bun.DeleteQuery tx bun.Tx
q *bun.DeleteQuery
resourceID any // Store ID before deletion for audit
auditCallback AuditCallback
auditRequest *http.Request
} }
type systemType interface { type systemType interface {
@@ -18,13 +23,27 @@ type systemType interface {
func DeleteItem[T any](tx bun.Tx) *deleter[T] { func DeleteItem[T any](tx bun.Tx) *deleter[T] {
return &deleter[T]{ return &deleter[T]{
tx.NewDelete(). tx: tx,
q: tx.NewDelete().
Model((*T)(nil)), Model((*T)(nil)),
} }
} }
func (d *deleter[T]) Where(query string, args ...any) *deleter[T] { func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
d.q = d.q.Where(query, args...) d.q = d.q.Where(query, args...)
// Try to capture resource ID from WHERE clause if it's a simple "id = ?" pattern
if query == "id = ?" && len(args) > 0 {
d.resourceID = args[0]
}
return d
}
// WithAudit enables audit logging for this delete operation
// The callback will be invoked after successful deletion with auto-generated audit info
// If the callback returns an error, the transaction will be rolled back
func (d *deleter[T]) WithAudit(r *http.Request, callback AuditCallback) *deleter[T] {
d.auditRequest = r
d.auditCallback = callback
return d return d
} }
@@ -34,8 +53,29 @@ func (d *deleter[T]) Delete(ctx context.Context) error {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil return nil
} }
return errors.Wrap(err, "bun.DeleteQuery.Exec")
} }
return errors.Wrap(err, "bun.DeleteQuery.Exec")
// Handle audit logging if enabled
if d.auditCallback != nil && d.auditRequest != nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "delete")
info := &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: d.resourceID,
Details: nil, // Delete doesn't need details
}
// Call audit callback - if it fails, return error to trigger rollback
if err := d.auditCallback(ctx, d.tx, info, d.auditRequest); err != nil {
return errors.Wrap(err, "audit.callback")
}
}
return nil
} }
func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] { func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {

View File

@@ -2,7 +2,6 @@ package db
import ( import (
"context" "context"
"database/sql"
"time" "time"
"git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/discord"
@@ -38,15 +37,14 @@ func (u *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord
TokenType: token.TokenType, TokenType: token.TokenType,
} }
_, err := tx.NewInsert(). err := Insert(tx, discordToken).
Model(discordToken).
On("CONFLICT (discord_id) DO UPDATE"). On("CONFLICT (discord_id) DO UPDATE").
Set("access_token = EXCLUDED.access_token"). Set("access_token = EXCLUDED.access_token").
Set("refresh_token = EXCLUDED.refresh_token"). Set("refresh_token = EXCLUDED.refresh_token").
Set("expires_at = EXCLUDED.expires_at"). Set("expires_at = EXCLUDED.expires_at").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewInsert") return errors.Wrap(err, "db.Insert")
} }
return nil return nil
} }
@@ -73,19 +71,7 @@ func (u *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToke
// GetDiscordToken retrieves the users discord token from the database // GetDiscordToken retrieves the users discord token from the database
func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) { func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
token := new(DiscordToken) return GetByField[DiscordToken](tx, "discord_id", u.DiscordID).GetFirst(ctx)
err := tx.NewSelect().
Model(token).
Where("discord_id = ?", u.DiscordID).
Limit(1).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return token, nil
} }
// Convert reverts the token back into a *discord.Token // Convert reverts the token back into a *discord.Token

128
internal/db/insert.go Normal file
View File

@@ -0,0 +1,128 @@
package db
import (
"context"
"net/http"
"strings"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type inserter[T any] struct {
tx bun.Tx
q *bun.InsertQuery
model *T
models []*T
isBulk bool
auditCallback AuditCallback
auditRequest *http.Request
}
// Insert creates an inserter for a single model
// The model will have all fields populated after Exec() via Returning("*")
func Insert[T any](tx bun.Tx, model *T) *inserter[T] {
if model == nil {
panic("model cannot be nil")
}
return &inserter[T]{
tx: tx,
q: tx.NewInsert().Model(model).Returning("*"),
model: model,
isBulk: false,
}
}
// InsertMultiple creates an inserter for bulk insert
// All models will have fields populated after Exec() via Returning("*")
func InsertMultiple[T any](tx bun.Tx, models []*T) *inserter[T] {
if len(models) == 0 {
panic("models cannot be nil or empty")
}
return &inserter[T]{
tx: tx,
q: tx.NewInsert().Model(&models).Returning("*"),
models: models,
isBulk: true,
}
}
// On adds .On handling for upserts
// Example: .On("(discord_id) DO UPDATE")
func (i *inserter[T]) On(query string) *inserter[T] {
i.q = i.q.On(query)
return i
}
// Set adds a SET clause for upserts (use with OnConflict)
// Example: .Set("access_token = EXCLUDED.access_token")
func (i *inserter[T]) Set(query string, args ...any) *inserter[T] {
i.q = i.q.Set(query, args...)
return i
}
// Returning overrides the default Returning("*") clause
// Example: .Returning("id", "created_at")
func (i *inserter[T]) Returning(columns ...string) *inserter[T] {
if len(columns) == 0 {
return i
}
// Build column list as single string
columnList := strings.Join(columns, ", ")
i.q = i.q.Returning(columnList)
return i
}
// WithAudit enables audit logging for this insert operation
// The callback will be invoked after successful insert with auto-generated audit info
// If the callback returns an error, the transaction will be rolled back
func (i *inserter[T]) WithAudit(r *http.Request, callback AuditCallback) *inserter[T] {
i.auditRequest = r
i.auditCallback = callback
return i
}
// Exec executes the insert and optionally logs to audit
// Returns an error if insert fails or if audit callback fails (triggering rollback)
func (i *inserter[T]) Exec(ctx context.Context) error {
// Execute insert
_, err := i.q.Exec(ctx)
if err != nil {
return errors.Wrap(err, "bun.InsertQuery.Exec")
}
// Handle audit logging if enabled
if i.auditCallback != nil && i.auditRequest != nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "create")
var info *AuditInfo
if i.isBulk {
// For bulk inserts, log once with count in details
info = &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: nil,
Details: map[string]any{
"count": len(i.models),
},
}
} else {
// For single insert, log with resource ID
info = &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: extractPrimaryKey(i.model),
Details: nil,
}
}
// Call audit callback - if it fails, return error to trigger rollback
if err := i.auditCallback(ctx, i.tx, info, i.auditRequest); err != nil {
return errors.Wrap(err, "audit.callback")
}
}
return nil
}

View File

@@ -34,20 +34,7 @@ func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permis
if name == "" { if name == "" {
return nil, errors.New("name cannot be empty") return nil, errors.New("name cannot be empty")
} }
return GetByField[Permission](tx, "name", name).GetFirst(ctx)
perm := new(Permission)
err := tx.NewSelect().
Model(perm).
Where("name = ?", name).
Limit(1).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perm, nil
} }
// GetPermissionByID queries the database for a permission matching the given ID // GetPermissionByID queries the database for a permission matching the given ID
@@ -56,20 +43,7 @@ func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, err
if id <= 0 { if id <= 0 {
return nil, errors.New("id must be positive") return nil, errors.New("id must be positive")
} }
return GetByID[Permission](tx, id).GetFirst(ctx)
perm := new(Permission)
err := tx.NewSelect().
Model(perm).
Where("id = ?", id).
Limit(1).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perm, nil
} }
// GetPermissionsByResource queries for all permissions for a given resource // GetPermissionsByResource queries for all permissions for a given resource
@@ -77,34 +51,8 @@ func GetPermissionsByResource(ctx context.Context, tx bun.Tx, resource string) (
if resource == "" { if resource == "" {
return nil, errors.New("resource cannot be empty") return nil, errors.New("resource cannot be empty")
} }
perms, err := GetByField[[]*Permission](tx, "resource", resource).GetAll(ctx)
perms := []*Permission{} return *perms, err
err := tx.NewSelect().
Model(&perms).
Where("resource = ?", resource).
Order("action ASC").
Scan(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perms, nil
}
// GetPermissionsByIDs queries for permissions matching the given IDs
func GetPermissionsByIDs(ctx context.Context, tx bun.Tx, ids []int) ([]*Permission, error) {
if len(ids) == 0 {
return []*Permission{}, nil
}
var perms []*Permission
err := tx.NewSelect().
Model(&perms).
Where("id IN (?)", bun.In(ids)).
Scan(ctx)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perms, nil
} }
// ListAllPermissions returns all permissions // ListAllPermissions returns all permissions
@@ -138,12 +86,11 @@ func CreatePermission(ctx context.Context, tx bun.Tx, perm *Permission) error {
return errors.New("action cannot be empty") return errors.New("action cannot be empty")
} }
_, err := tx.NewInsert(). err := Insert(tx, perm).
Model(perm).
Returning("id"). Returning("id").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewInsert") return errors.Wrap(err, "db.Insert")
} }
return nil return nil

View File

@@ -77,12 +77,11 @@ func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
} }
role.CreatedAt = time.Now().Unix() role.CreatedAt = time.Now().Unix()
_, err := tx.NewInsert(). err := Insert(tx, role).
Model(role).
Returning("id"). Returning("id").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewInsert") return errors.Wrap(err, "db.Insert")
} }
return nil return nil
@@ -97,12 +96,11 @@ func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
return errors.New("role id must be positive") return errors.New("role id must be positive")
} }
_, err := tx.NewUpdate(). err := Update(tx, role).
Model(role).
WherePK(). WherePK().
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewUpdate") return errors.Wrap(err, "db.Update")
} }
return nil return nil
@@ -128,12 +126,11 @@ func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID in
RoleID: roleID, RoleID: roleID,
PermissionID: permissionID, PermissionID: permissionID,
} }
_, err := tx.NewInsert(). err := Insert(tx, rolePerm).
Model(rolePerm).
On("CONFLICT (role_id, permission_id) DO NOTHING"). On("CONFLICT (role_id, permission_id) DO NOTHING").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewInsert") return errors.Wrap(err, "db.Insert")
} }
return nil return nil

View File

@@ -13,34 +13,22 @@ 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"`
Name string `bun:"name,unique"` Name string `bun:"name,unique,notnull"`
ShortName string `bun:"short_name,unique"` ShortName string `bun:"short_name,unique,notnull"`
StartDate time.Time `bun:"start_date,notnull"` StartDate time.Time `bun:"start_date,notnull"`
EndDate bun.NullTime `bun:"end_date"` EndDate bun.NullTime `bun:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date"`
} }
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) { // NewSeason returns a new season. It does not add it to the database
if name == "" { func NewSeason(name, shortname string, start time.Time) *Season {
return nil, errors.New("name cannot be empty")
}
if shortname == "" {
return nil, errors.New("shortname cannot be empty")
}
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),
} }
_, err := tx.NewInsert(). return season
Model(season).
Returning("id").
Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewInsert")
}
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) {
@@ -59,3 +47,17 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error
} }
return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx) return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx)
} }
// Update updates the season struct. It does not insert to the database
func (s *Season) Update(start, end, finalsStart, finalsEnd time.Time) {
s.StartDate = start.Truncate(time.Hour * 24)
if !end.IsZero() {
s.EndDate.Time = end.Truncate(time.Hour * 24)
}
if !finalsStart.IsZero() {
s.FinalsStartDate.Time = finalsStart.Truncate(time.Hour * 24)
}
if !finalsEnd.IsZero() {
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24)
}
}

115
internal/db/update.go Normal file
View File

@@ -0,0 +1,115 @@
package db
import (
"context"
"net/http"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type updater[T any] struct {
tx bun.Tx
q *bun.UpdateQuery
model *T
columns []string
auditCallback AuditCallback
auditRequest *http.Request
}
// Update creates an updater for a model
// You must specify which columns to update via .Column() or use .WherePK()
func Update[T any](tx bun.Tx, model *T) *updater[T] {
if model == nil {
panic("model cannot be nil")
}
return &updater[T]{
tx: tx,
q: tx.NewUpdate().Model(model),
model: model,
}
}
// UpdateByID creates an updater with an ID where clause
// You must still specify which columns to update via .Column()
func UpdateByID[T any](tx bun.Tx, id int, model *T) *updater[T] {
if id <= 0 {
panic("id must be positive")
}
return Update(tx, model).Where("id = ?", id)
}
// Column specifies which columns to update
// Example: .Column("start_date", "end_date")
func (u *updater[T]) Column(columns ...string) *updater[T] {
u.columns = append(u.columns, columns...)
u.q = u.q.Column(columns...)
return u
}
// Where adds a WHERE clause
// Example: .Where("id = ?", 123)
func (u *updater[T]) Where(query string, args ...any) *updater[T] {
u.q = u.q.Where(query, args...)
return u
}
// WherePK adds a WHERE clause on the primary key
// The model must have its primary key field populated
func (u *updater[T]) WherePK() *updater[T] {
u.q = u.q.WherePK()
return u
}
// Set adds a raw SET clause for complex updates
// Example: .Set("updated_at = NOW()")
func (u *updater[T]) Set(query string, args ...any) *updater[T] {
u.q = u.q.Set(query, args...)
return u
}
// WithAudit enables audit logging for this update operation
// The callback will be invoked after successful update with auto-generated audit info
// If the callback returns an error, the transaction will be rolled back
func (u *updater[T]) WithAudit(r *http.Request, callback AuditCallback) *updater[T] {
u.auditRequest = r
u.auditCallback = callback
return u
}
// Exec executes the update and optionally logs to audit
// Returns an error if update fails or if audit callback fails (triggering rollback)
func (u *updater[T]) Exec(ctx context.Context) error {
// Build audit details BEFORE update (captures changed fields)
var details map[string]any
if u.auditCallback != nil && len(u.columns) > 0 {
details = extractChangedFields(u.model, u.columns)
}
// Execute update
_, err := u.q.Exec(ctx)
if err != nil {
return errors.Wrap(err, "bun.UpdateQuery.Exec")
}
// Handle audit logging if enabled
if u.auditCallback != nil && u.auditRequest != nil {
tableName := extractTableName[T]()
resourceType := extractResourceType(tableName)
action := buildAction(resourceType, "update")
info := &AuditInfo{
Action: action,
ResourceType: resourceType,
ResourceID: extractPrimaryKey(u.model),
Details: details, // Changed fields only
}
// Call audit callback - if it fails, return error to trigger rollback
if err := u.auditCallback(ctx, u.tx, info, u.auditRequest); err != nil {
return errors.Wrap(err, "audit.callback")
}
}
return nil
}

View File

@@ -40,12 +40,11 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
DiscordID: discorduser.ID, DiscordID: discorduser.ID,
} }
_, err := tx.NewInsert(). err := Insert(tx, user).
Model(user).
Returning("id"). Returning("id").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "tx.NewInsert") return nil, errors.Wrap(err, "db.Insert")
} }
return user, nil return user, nil

View File

@@ -29,12 +29,11 @@ func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
UserID: userID, UserID: userID,
RoleID: roleID, RoleID: roleID,
} }
_, err := tx.NewInsert(). err := Insert(tx, userRole).
Model(userRole).
On("CONFLICT (user_id, role_id) DO NOTHING"). On("CONFLICT (user_id, role_id) DO NOTHING").
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.NewInsert") return errors.Wrap(err, "db.Insert")
} }
return nil return nil

View File

@@ -279,6 +279,9 @@
.mx-auto { .mx-auto {
margin-inline: auto; margin-inline: auto;
} }
.-mt-2 {
margin-top: calc(var(--spacing) * -2);
}
.mt-1 { .mt-1 {
margin-top: calc(var(--spacing) * 1); margin-top: calc(var(--spacing) * 1);
} }
@@ -523,6 +526,13 @@
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-4 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
}
}
.gap-x-2 { .gap-x-2 {
column-gap: calc(var(--spacing) * 2); column-gap: calc(var(--spacing) * 2);
} }
@@ -570,6 +580,10 @@
.rounded-xl { .rounded-xl {
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
} }
.rounded-t-lg {
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
}
.border { .border {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 1px; border-width: 1px;
@@ -648,6 +662,12 @@
.bg-mauve { .bg-mauve {
background-color: var(--mauve); background-color: var(--mauve);
} }
.bg-red\/10 {
background-color: var(--red);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--red) 10%, transparent);
}
}
.bg-sapphire { .bg-sapphire {
background-color: var(--sapphire); background-color: var(--sapphire);
} }

View File

@@ -43,10 +43,7 @@ func NotificationWS(
if err != nil { if err != nil {
logError(s, "Notification error", errors.Wrap(err, "notifyLoop")) logError(s, "Notification error", errors.Wrap(err, "notifyLoop"))
} }
err = ws.CloseNow() _ = ws.CloseNow()
if err != nil {
logError(s, "Error closing websocket", errors.Wrap(err, "ws.CloseNow"))
}
}, },
) )
} }

View File

@@ -71,6 +71,12 @@ func Register(
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.UpdateDiscordToken") return false, errors.Wrap(err, "db.UpdateDiscordToken")
} }
if shouldGrantAdmin(user, cfg.RBAC) {
err := ensureUserHasAdminRole(ctx, tx, user)
if err != nil {
return false, errors.Wrap(err, "ensureUserHasAdminRole")
}
}
return true, nil return true, nil
}); !ok { }); !ok {
return return

View File

@@ -0,0 +1,103 @@
package handlers
import (
"context"
"fmt"
"net/http"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/auditlog"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/notify"
"git.haelnorr.com/h/oslstats/internal/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"
)
func SeasonEditPage(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
var season *db.Season
if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
return true, nil
}); !ok {
return
}
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
renderSafely(seasonsview.EditPage(season), s, r, w)
})
}
func SeasonEditSubmit(
s *hws.Server,
conn *bun.DB,
audit *auditlog.Logger,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seasonStr := r.PathValue("season_short_name")
getter, ok := validation.ParseFormOrNotify(s, w, r)
if !ok {
return
}
format := timefmt.NewBuilder().
DayNumeric2().Slash().
MonthNumeric2().Slash().
Year4().Build()
startDate := getter.Time("start_date", format).Required().Value
endDate := getter.Time("end_date", format).Value
finalsStartDate := getter.Time("finals_start_date", format).Value
finalsEndDate := getter.Time("finals_end_date", format).Value
if !getter.ValidateAndNotify(s, w, r) {
return
}
var season *db.Season
if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) {
var err error
season, err = db.GetSeason(ctx, tx, seasonStr)
if err != nil {
return false, errors.Wrap(err, "db.GetSeason")
}
if season == nil {
return false, errors.New("season does not exist")
}
season.Update(startDate, endDate, finalsStartDate, finalsEndDate)
err = db.Update(tx, season).WherePK().
Column("start_date", "end_date", "finals_start_date", "finals_end_date").
WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil {
return false, errors.Wrap(err, "db.Update")
}
return true, nil
}); !ok {
return
}
if season == nil {
throw.NotFound(s, w, r, r.URL.Path)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
})
}

View File

@@ -6,6 +6,7 @@ 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"
@@ -30,6 +31,7 @@ func NewSeason(
func NewSeasonSubmit( func NewSeasonSubmit(
s *hws.Server, s *hws.Server,
conn *bun.DB, conn *bun.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)
@@ -67,9 +69,10 @@ func NewSeasonSubmit(
if !nameUnique || !shortNameUnique { if !nameUnique || !shortNameUnique {
return true, nil return true, nil
} }
season, err = db.NewSeason(ctx, tx, name, shortName, startDate) season = db.NewSeason(name, shortName, startDate)
err = db.Insert(tx, season).WithAudit(r, audit.Callback()).Exec(ctx)
if err != nil { if err != nil {
return false, errors.Wrap(err, "db.NewSeason") return false, errors.Wrap(err, "db.Insert")
} }
return true, nil return true, nil
}); !ok { }); !ok {
@@ -85,9 +88,8 @@ func NewSeasonSubmit(
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil) notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
return return
} }
notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName)) w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
}) })
} }

View File

@@ -3,6 +3,7 @@ package notify
import ( import (
"net/http" "net/http"
"time"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/notify" "git.haelnorr.com/h/golib/notify"
@@ -61,3 +62,11 @@ func Info(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg stri
func Success(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) { func Success(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action) notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
} }
// SuccessWithDelay notifies with success level and a short delay to account for redirects
func SuccessWithDelay(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
go func() {
<-time.Tick(1 * time.Second)
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
}()
}

View File

@@ -27,60 +27,94 @@ package datepicker
// The component submits the date in DD/MM/YYYY format. To parse on the server: // The component submits the date in DD/MM/YYYY format. To parse on the server:
// date, err := time.Parse("02/01/2006", dateString) // date, err := time.Parse("02/01/2006", dateString)
templ DatePicker(id, name, label, placeholder string, required bool, onChange string) { templ DatePicker(id, name, label, placeholder string, required bool, onChange string) {
@DatePickerWithDefault(id, name, label, placeholder, required, onChange, "")
}
// DatePickerWithDefault is the same as DatePicker but accepts a default value in DD/MM/YYYY format
templ DatePickerWithDefault(id, name, label, placeholder string, required bool, onChange, defaultValue string) {
<div <div
x-data="{ x-data={ templ.JSFuncCall("datePickerData", defaultValue).CallInline }
showPicker: false,
selectedDate: '',
displayDate: '',
tempYear: new Date().getFullYear(),
tempMonth: new Date().getMonth(),
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
init() {
// Ensure dropdowns are initialized with current month/year
this.tempYear = new Date().getFullYear();
this.tempMonth = new Date().getMonth();
},
getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
},
getFirstDayOfMonth(year, month) {
return new Date(year, month, 1).getDay();
},
selectDate(day) {
const d = String(day).padStart(2, '0');
const m = String(this.tempMonth + 1).padStart(2, '0');
const y = this.tempYear;
this.displayDate = d + '/' + m + '/' + y;
this.selectedDate = this.displayDate;
$refs.dateInput.value = this.displayDate;
this.showPicker = false;
// Manually trigger input event so Alpine.js @input handler fires
$refs.dateInput.dispatchEvent(new Event('input', { bubbles: true }));
},
prevMonth() {
if (this.tempMonth === 0) {
this.tempMonth = 11;
this.tempYear--;
} else {
this.tempMonth--;
}
},
nextMonth() {
if (this.tempMonth === 11) {
this.tempMonth = 0;
this.tempYear++;
} else {
this.tempMonth++;
}
},
isToday(day) {
const today = new Date();
return day === today.getDate() &&
this.tempMonth === today.getMonth() &&
this.tempYear === today.getFullYear();
}
}"
> >
<script>
function datePickerData(defaultValue) {
return {
showPicker: false,
selectedDate: defaultValue || "",
displayDate: defaultValue || "",
tempYear: new Date().getFullYear(),
tempMonth: new Date().getMonth(),
months: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
init() {
if (this.displayDate) {
const parts = this.displayDate.split("/");
if (parts.length === 3) {
this.tempYear = parseInt(parts[2]);
this.tempMonth = parseInt(parts[1]) - 1;
}
} else {
this.tempYear = new Date().getFullYear();
this.tempMonth = new Date().getMonth();
}
},
getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
},
getFirstDayOfMonth(year, month) {
return new Date(year, month, 1).getDay();
},
selectDate(day) {
const d = String(day).padStart(2, "0");
const m = String(this.tempMonth + 1).padStart(2, "0");
const y = this.tempYear;
this.displayDate = d + "/" + m + "/" + y;
this.selectedDate = this.displayDate;
this.$refs.dateInput.value = this.displayDate;
this.showPicker = false;
// Manually trigger input event so Alpine.js @input handler fires
this.$refs.dateInput.dispatchEvent(
new Event("input", { bubbles: true }),
);
},
prevMonth() {
if (this.tempMonth === 0) {
this.tempMonth = 11;
this.tempYear--;
} else {
this.tempMonth--;
}
},
nextMonth() {
if (this.tempMonth === 11) {
this.tempMonth = 0;
this.tempYear++;
} else {
this.tempMonth++;
}
},
isToday(day) {
const today = new Date();
return (
day === today.getDate() &&
this.tempMonth === today.getMonth() &&
this.tempYear === today.getFullYear()
);
},
};
}
</script>
<label for={ id } class="block text-sm font-medium mb-2">{ label }</label> <label for={ id } class="block text-sm font-medium mb-2">{ label }</label>
<div class="relative"> <div class="relative">
<input <input

View File

@@ -144,10 +144,8 @@ func formatDateLong(t time.Time) string {
} }
func formatDuration(start, end time.Time) string { func formatDuration(start, end time.Time) string {
days := int(end.Sub(start).Hours() / 24) days := int(end.Sub(start).Hours()/24) + 1
if days == 0 { if days == 1 {
return "Same day"
} else if days == 1 {
return "1 day" return "1 day"
} else if days < 7 { } else if days < 7 {
return strconv.Itoa(days) + " days" return strconv.Itoa(days) + " days"

View File

@@ -0,0 +1,177 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/view/datepicker"
import "git.haelnorr.com/h/oslstats/internal/db"
import "time"
templ EditForm(season *db.Season) {
{{
// Format dates for display (DD/MM/YYYY)
startDateStr := formatDateInput(season.StartDate)
endDateStr := ""
if !season.EndDate.IsZero() {
endDateStr = formatDateInput(season.EndDate.Time)
}
finalsStartDateStr := ""
if !season.FinalsStartDate.IsZero() {
finalsStartDateStr = formatDateInput(season.FinalsStartDate.Time)
}
finalsEndDateStr := ""
if !season.FinalsEndDate.IsZero() {
finalsEndDateStr = formatDateInput(season.FinalsEndDate.Time)
}
}}
<form
hx-post={ "/seasons/" + season.ShortName + "/edit" }
hx-swap="none"
x-data={ templ.JSFuncCall("editSeasonFormData", startDateStr, endDateStr, finalsStartDateStr, finalsEndDateStr).CallInline }
@submit="handleSubmit()"
@htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); const redirect = $event.detail.xhr.getResponseHeader('HX-Redirect'); if(redirect) return; if(!$event.detail.successful) { isSubmitting=false; buttonText='Save Changes'; generalError='An error occurred. Please try again.'; }"
>
<script>
function editSeasonFormData(
initialStart,
initialEnd,
initialFinalsStart,
initialFinalsEnd,
) {
return {
canSubmit: true,
buttonText: "Save Changes",
// Date validation state
startDateError: "",
startDateIsEmpty: initialStart === "",
endDateError: "",
finalsStartDateError: "",
finalsEndDateError: "",
// Form state
isSubmitting: false,
generalError: "",
submitTimeout: null,
// Reset date errors
resetStartDateErr() {
this.startDateError = "";
},
resetEndDateErr() {
this.endDateError = "";
},
resetFinalsStartDateErr() {
this.finalsStartDateError = "";
},
resetFinalsEndDateErr() {
this.finalsEndDateError = "";
},
// Check if form can be submitted
updateCanSubmit() {
this.canSubmit = !this.startDateIsEmpty;
},
// Handle form submission
handleSubmit() {
this.isSubmitting = true;
this.buttonText = "Saving...";
this.generalError = "";
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttonText = "Save Changes";
this.generalError = "Request timed out. Please try again.";
}, 10000);
},
};
}
</script>
<div class="bg-mantle border border-surface1 rounded-lg">
<!-- Header Section -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8 rounded-t-lg">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-4xl font-bold text-text mb-2">Edit { season.Name }</h1>
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
{ season.ShortName }
</span>
</div>
<div class="flex gap-2">
<button
x-bind:disabled="!canSubmit || isSubmitting"
x-text="buttonText"
type="submit"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-blue hover:bg-blue/75 text-mantle transition font-semibold
disabled:bg-blue/40 disabled:cursor-not-allowed"
></button>
<a
href={ templ.SafeURL("/seasons/" + season.ShortName) }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Cancel
</a>
</div>
</div>
</div>
<!-- Information Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
<!-- Regular Season Section -->
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
<span class="text-blue">●</span>
Regular Season
</h2>
<div class="space-y-4">
@datepicker.DatePickerWithDefault("start_date", "start_date", "Start Date", "DD/MM/YYYY", true, "startDateIsEmpty = $el.value === ''; resetStartDateErr(); if(startDateIsEmpty) { startDateError='Start date is required'; } updateCanSubmit();", startDateStr)
<p
class="text-xs text-red -mt-2"
x-show="startDateError && !isSubmitting"
x-cloak
x-text="startDateError"
></p>
@datepicker.DatePickerWithDefault("end_date", "end_date", "End Date (Optional)", "DD/MM/YYYY", false, "resetEndDateErr();", endDateStr)
<p
class="text-xs text-red -mt-2"
x-show="endDateError && !isSubmitting"
x-cloak
x-text="endDateError"
></p>
</div>
</div>
<!-- Finals Section -->
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
<span class="text-yellow">★</span>
Finals
</h2>
<div class="space-y-4">
@datepicker.DatePickerWithDefault("finals_start_date", "finals_start_date", "Start Date (Optional)", "DD/MM/YYYY", false, "resetFinalsStartDateErr();", finalsStartDateStr)
<p
class="text-xs text-red -mt-2"
x-show="finalsStartDateError && !isSubmitting"
x-cloak
x-text="finalsStartDateError"
></p>
@datepicker.DatePickerWithDefault("finals_end_date", "finals_end_date", "End Date (Optional)", "DD/MM/YYYY", false, "resetFinalsEndDateErr();", finalsEndDateStr)
<p
class="text-xs text-red -mt-2"
x-show="finalsEndDateError && !isSubmitting"
x-cloak
x-text="finalsEndDateError"
></p>
</div>
</div>
</div>
<!-- General Error Message -->
<div
class="px-6 pb-6"
x-show="generalError"
x-cloak
>
<div class="bg-red/10 border border-red rounded-lg p-4">
<p class="text-sm text-red text-center" x-text="generalError"></p>
</div>
</div>
</div>
</form>
}
func formatDateInput(t time.Time) string {
return t.Format("02/01/2006")
}

View File

@@ -0,0 +1,12 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/view/baseview"
import "git.haelnorr.com/h/oslstats/internal/db"
templ EditPage(season *db.Season) {
@baseview.Layout("Edit " + season.Name) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
@EditForm(season)
</div>
}
}