fixed some migration issues and added generics for update and insert

This commit is contained in:
2026-02-09 21:58:50 +11:00
parent 292ec93de7
commit bb52790faa
19 changed files with 591 additions and 261 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

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("Connecting to database")
bun, closedb := setupBun(cfg)
registerDBModels(bun)
// registerDBModels(bun)
// Setup 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/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=

View File

@@ -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,
)
}
}

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

@@ -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 {
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")
}
// 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] {

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,
}
}
// 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
}

View File

@@ -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
}

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

@@ -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;

View File

@@ -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()
},
)
}

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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)
}()
}

View File

@@ -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"