diff --git a/AGENTS.md b/AGENTS.md index 2b325a9..5f6eaa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ This document provides guidelines for AI coding agents and developers working on ## Build, Test, and Development Commands ### Building +NEVER BUILD MANUALLY ```bash # Full production build (tailwind → templ → go generate → go build) make build diff --git a/cmd/oslstats/db.go b/cmd/oslstats/db.go index bf5e796..96e075a 100644 --- a/cmd/oslstats/db.go +++ b/cmd/oslstats/db.go @@ -23,11 +23,12 @@ func setupBun(cfg *config.Config) (conn *bun.DB, close func() error) { sqldb.SetConnMaxIdleTime(5 * time.Minute) conn = bun.NewDB(sqldb, pgdialect.New()) + registerDBModels(conn) close = sqldb.Close return conn, close } -func registerDBModels(conn *bun.DB) { +func registerDBModels(conn *bun.DB) []any { models := []any{ (*db.RolePermission)(nil), (*db.UserRole)(nil), @@ -39,4 +40,5 @@ func registerDBModels(conn *bun.DB) { (*db.AuditLog)(nil), } conn.RegisterModel(models...) + return models } diff --git a/cmd/oslstats/migrate.go b/cmd/oslstats/migrate.go index 1564d63..7ecc9a8 100644 --- a/cmd/oslstats/migrate.go +++ b/cmd/oslstats/migrate.go @@ -15,7 +15,6 @@ import ( "git.haelnorr.com/h/oslstats/cmd/oslstats/migrations" "git.haelnorr.com/h/oslstats/internal/backup" "git.haelnorr.com/h/oslstats/internal/config" - "git.haelnorr.com/h/oslstats/internal/db" "github.com/pkg/errors" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" @@ -349,10 +348,7 @@ func resetDatabase(ctx context.Context, cfg *config.Config) error { conn, close := setupBun(cfg) defer func() { _ = close() }() - models := []any{ - (*db.User)(nil), - (*db.DiscordToken)(nil), - } + models := registerDBModels(conn) for _, model := range models { if err := conn.ResetModel(ctx, model); err != nil { diff --git a/cmd/oslstats/migrations/20260202231414_add_rbac_system.go b/cmd/oslstats/migrations/20260202231414_add_rbac_system.go index 73684a9..4f986b8 100644 --- a/cmd/oslstats/migrations/20260202231414_add_rbac_system.go +++ b/cmd/oslstats/migrations/20260202231414_add_rbac_system.go @@ -5,6 +5,7 @@ import ( "time" "git.haelnorr.com/h/oslstats/internal/db" + "github.com/pkg/errors" "github.com/uptrace/bun" ) @@ -141,78 +142,7 @@ func init() { return err } - // 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 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) + err = seedSystemRBAC(ctx, dbConn) if err != nil { 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 +} diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go index ee831df..030652e 100644 --- a/cmd/oslstats/run.go +++ b/cmd/oslstats/run.go @@ -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("Connecting to database") bun, closedb := setupBun(cfg) - registerDBModels(bun) + // registerDBModels(bun) // Setup embedded files logger.Debug().Msg("Getting embedded files") diff --git a/go.sum b/go.sum index 95c72f1..4068444 100644 --- a/go.sum +++ b/go.sum @@ -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/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c= git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM= -git.haelnorr.com/h/golib/hwsauth v0.6.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/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= diff --git a/internal/auditlog/logger.go b/internal/auditlog/logger.go index 61d5e98..5b6abca 100644 --- a/internal/auditlog/logger.go +++ b/internal/auditlog/logger.go @@ -156,3 +156,32 @@ func (l *Logger) CleanupOldLogs(ctx context.Context, daysToKeep int) (int, error } 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, + ) + } +} diff --git a/internal/db/audit.go b/internal/db/audit.go new file mode 100644 index 0000000..bb89e8b --- /dev/null +++ b/internal/db/audit.go @@ -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 diff --git a/internal/db/delete.go b/internal/db/delete.go index 2f3c826..d8f8458 100644 --- a/internal/db/delete.go +++ b/internal/db/delete.go @@ -3,13 +3,18 @@ package db import ( "context" "database/sql" + "net/http" "github.com/pkg/errors" "github.com/uptrace/bun" ) 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 { @@ -18,13 +23,27 @@ type systemType interface { func DeleteItem[T any](tx bun.Tx) *deleter[T] { return &deleter[T]{ - tx.NewDelete(). + tx: tx, + q: tx.NewDelete(). Model((*T)(nil)), } } func (d *deleter[T]) Where(query string, args ...any) *deleter[T] { 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 } @@ -34,8 +53,29 @@ func (d *deleter[T]) Delete(ctx context.Context) error { if errors.Is(err, sql.ErrNoRows) { 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] { diff --git a/internal/db/insert.go b/internal/db/insert.go new file mode 100644 index 0000000..c2211f1 --- /dev/null +++ b/internal/db/insert.go @@ -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, + } +} + +// OnConflict adds conflict handling for upserts +// Example: .OnConflict("(discord_id) DO UPDATE") +func (i *inserter[T]) OnConflict(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 +} diff --git a/internal/db/season.go b/internal/db/season.go index 6059a9a..bff84ce 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -13,34 +13,22 @@ type Season struct { bun.BaseModel `bun:"table:seasons,alias:s"` ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name,unique"` - ShortName string `bun:"short_name,unique"` + Name string `bun:"name,unique,notnull"` + ShortName string `bun:"short_name,unique,notnull"` StartDate time.Time `bun:"start_date,notnull"` EndDate bun.NullTime `bun:"end_date"` FinalsStartDate bun.NullTime `bun:"finals_start_date"` FinalsEndDate bun.NullTime `bun:"finals_end_date"` } -func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) { - if name == "" { - return nil, errors.New("name cannot be empty") - } - if shortname == "" { - return nil, errors.New("shortname cannot be empty") - } +// NewSeason returns a new season. It does not add it to the database +func NewSeason(name, shortname string, start time.Time) *Season { season := &Season{ Name: name, ShortName: strings.ToUpper(shortname), StartDate: start.Truncate(time.Hour * 24), } - _, err := tx.NewInsert(). - Model(season). - Returning("id"). - Exec(ctx) - if err != nil { - return nil, errors.Wrap(err, "tx.NewInsert") - } - return season, nil + return season } func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Season], error) { @@ -60,31 +48,16 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx) } -func UpdateSeason(ctx context.Context, tx bun.Tx, season *Season) error { - if season == nil { - return errors.New("season cannot be nil") +// 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 season.ID == 0 { - return errors.New("season ID cannot be 0") + if !finalsStart.IsZero() { + s.FinalsStartDate.Time = finalsStart.Truncate(time.Hour * 24) } - // Truncate dates to day precision - season.StartDate = season.StartDate.Truncate(time.Hour * 24) - if !season.EndDate.IsZero() { - season.EndDate.Time = season.EndDate.Time.Truncate(time.Hour * 24) + if !finalsEnd.IsZero() { + s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24) } - if !season.FinalsStartDate.IsZero() { - season.FinalsStartDate.Time = season.FinalsStartDate.Time.Truncate(time.Hour * 24) - } - if !season.FinalsEndDate.IsZero() { - season.FinalsEndDate.Time = season.FinalsEndDate.Time.Truncate(time.Hour * 24) - } - _, err := tx.NewUpdate(). - Model(season). - Column("start_date", "end_date", "finals_start_date", "finals_end_date"). - Where("id = ?", season.ID). - Exec(ctx) - if err != nil { - return errors.Wrap(err, "tx.NewUpdate") - } - return nil } diff --git a/internal/db/update.go b/internal/db/update.go new file mode 100644 index 0000000..2f26cb5 --- /dev/null +++ b/internal/db/update.go @@ -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 +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 618d038..f086ec1 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -45,36 +45,6 @@ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); - --color-rosewater: var(--rosewater); - --color-flamingo: var(--flamingo); - --color-pink: var(--pink); - --color-mauve: var(--mauve); - --color-red: var(--red); - --color-dark-red: var(--dark-red); - --color-maroon: var(--maroon); - --color-peach: var(--peach); - --color-yellow: var(--yellow); - --color-dark-yellow: var(--dark-yellow); - --color-green: var(--green); - --color-dark-green: var(--dark-green); - --color-teal: var(--teal); - --color-sky: var(--sky); - --color-sapphire: var(--sapphire); - --color-blue: var(--blue); - --color-dark-blue: var(--dark-blue); - --color-lavender: var(--lavender); - --color-text: var(--text); - --color-subtext1: var(--subtext1); - --color-subtext0: var(--subtext0); - --color-overlay2: var(--overlay2); - --color-overlay1: var(--overlay1); - --color-overlay0: var(--overlay0); - --color-surface2: var(--surface2); - --color-surface1: var(--surface1); - --color-surface0: var(--surface0); - --color-base: var(--base); - --color-mantle: var(--mantle); - --color-crust: var(--crust); } } @layer base { @@ -273,9 +243,6 @@ .top-0 { top: calc(var(--spacing) * 0); } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1/2 * 100%); } @@ -309,24 +276,6 @@ .z-50 { z-index: 50; } - .container { - width: 100%; - @media (width >= 40rem) { - max-width: 40rem; - } - @media (width >= 48rem) { - max-width: 48rem; - } - @media (width >= 64rem) { - max-width: 64rem; - } - @media (width >= 80rem) { - max-width: 80rem; - } - @media (width >= 96rem) { - max-width: 96rem; - } - } .mx-auto { margin-inline: auto; } @@ -496,25 +445,9 @@ .flex-1 { flex: 1; } - .flex-shrink { - flex-shrink: 1; - } .shrink-0 { flex-shrink: 0; } - .flex-grow { - flex-grow: 1; - } - .grow { - flex-grow: 1; - } - .border-collapse { - border-collapse: collapse; - } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -623,11 +556,6 @@ border-color: var(--surface2); } } - .truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } .overflow-hidden { overflow: hidden; } @@ -734,9 +662,6 @@ .bg-mauve { background-color: var(--mauve); } - .bg-red { - background-color: var(--red); - } .bg-red\/10 { background-color: var(--red); @supports (color: color-mix(in lab, red, red)) { @@ -933,9 +858,6 @@ .italic { font-style: italic; } - .underline { - text-decoration-line: underline; - } .opacity-50 { opacity: 50%; } @@ -951,10 +873,6 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -1539,11 +1457,6 @@ inherits: false; initial-value: 0 0 #0000; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -1628,7 +1541,6 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/internal/handlers/notifswebsocket.go b/internal/handlers/notifswebsocket.go index 0a174ee..34c8978 100644 --- a/internal/handlers/notifswebsocket.go +++ b/internal/handlers/notifswebsocket.go @@ -43,10 +43,7 @@ func NotificationWS( if err != nil { logError(s, "Notification error", errors.Wrap(err, "notifyLoop")) } - err = ws.CloseNow() - if err != nil { - logError(s, "Error closing websocket", errors.Wrap(err, "ws.CloseNow")) - } + _ = ws.CloseNow() }, ) } diff --git a/internal/handlers/register.go b/internal/handlers/register.go index 74e4030..0ef308c 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -71,6 +71,12 @@ func Register( if err != nil { return false, errors.Wrap(err, "db.UpdateDiscordToken") } + if shouldGrantAdmin(user, cfg.RBAC) { + err := ensureUserHasAdminRole(ctx, tx, user) + if err != nil { + return false, errors.Wrap(err, "ensureUserHasAdminRole") + } + } return true, nil }); !ok { return diff --git a/internal/handlers/season_edit.go b/internal/handlers/season_edit.go index 382ca84..20be076 100644 --- a/internal/handlers/season_edit.go +++ b/internal/handlers/season_edit.go @@ -9,7 +9,6 @@ import ( "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/permissions" "git.haelnorr.com/h/oslstats/internal/throw" "git.haelnorr.com/h/oslstats/internal/validation" "git.haelnorr.com/h/oslstats/internal/view/seasonsview" @@ -78,36 +77,14 @@ func SeasonEditSubmit( return false, errors.Wrap(err, "db.GetSeason") } if season == nil { - return true, nil + return false, errors.New("season does not exist") } - - // Update only the date fields - season.StartDate = startDate - if !endDate.IsZero() { - season.EndDate = bun.NullTime{Time: endDate} - } else { - season.EndDate = bun.NullTime{} - } - if !finalsStartDate.IsZero() { - season.FinalsStartDate = bun.NullTime{Time: finalsStartDate} - } else { - season.FinalsStartDate = bun.NullTime{} - } - if !finalsEndDate.IsZero() { - season.FinalsEndDate = bun.NullTime{Time: finalsEndDate} - } else { - season.FinalsEndDate = bun.NullTime{} - } - - err = db.UpdateSeason(ctx, tx, season) + 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.UpdateSeason") - } - user := db.CurrentUser(ctx) - err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(), - "season", season.ID, nil, r) - if err != nil { - return false, errors.Wrap(err, "audit.LogSuccess") + return false, errors.Wrap(err, "db.Update") } return true, nil }); !ok { @@ -119,8 +96,8 @@ func SeasonEditSubmit( return } - notify.Success(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil) 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) }) } diff --git a/internal/handlers/seasons_new.go b/internal/handlers/seasons_new.go index 24e6e4b..ed7eadb 100644 --- a/internal/handlers/seasons_new.go +++ b/internal/handlers/seasons_new.go @@ -9,7 +9,6 @@ import ( "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/permissions" "git.haelnorr.com/h/oslstats/internal/validation" seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview" "git.haelnorr.com/h/timefmt" @@ -70,15 +69,10 @@ func NewSeasonSubmit( if !nameUnique || !shortNameUnique { 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 { - return false, errors.Wrap(err, "db.NewSeason") - } - user := db.CurrentUser(ctx) - err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(), - "season", season.ID, nil, r) - if err != nil { - return false, errors.Wrap(err, "audit.LogSuccess") + return false, errors.Wrap(err, "db.Insert") } return true, nil }); !ok { @@ -94,8 +88,8 @@ func NewSeasonSubmit( notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil) 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.WriteHeader(http.StatusOK) + notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil) }) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index be3ce60..d89328f 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -3,6 +3,7 @@ package notify import ( "net/http" + "time" "git.haelnorr.com/h/golib/hws" "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) { 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) + }() +} diff --git a/internal/view/seasonsview/detail_page.templ b/internal/view/seasonsview/detail_page.templ index 6e77959..c95c76e 100644 --- a/internal/view/seasonsview/detail_page.templ +++ b/internal/view/seasonsview/detail_page.templ @@ -144,10 +144,8 @@ func formatDateLong(t time.Time) string { } func formatDuration(start, end time.Time) string { - days := int(end.Sub(start).Hours() / 24) - if days == 0 { - return "1 day" - } else if days == 1 { + days := int(end.Sub(start).Hours()/24) + 1 + if days == 1 { return "1 day" } else if days < 7 { return strconv.Itoa(days) + " days"