Compare commits
3 Commits
aaf532b835
...
a4b4f4f4af
| Author | SHA1 | Date | |
|---|---|---|---|
| a4b4f4f4af | |||
| b89ee75ca7 | |||
| bf3e526f1e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||
"git.haelnorr.com/h/oslstats/internal/store"
|
||||
)
|
||||
@@ -83,18 +84,28 @@ func addRoutes(
|
||||
{
|
||||
Path: "/seasons/new",
|
||||
Method: hws.MethodGET,
|
||||
Handler: handlers.NewSeason(s, conn),
|
||||
Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeason(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/seasons/new",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: handlers.NewSeasonSubmit(s, conn),
|
||||
Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeasonSubmit(s, conn, audit)),
|
||||
},
|
||||
{
|
||||
Path: "/seasons/{season_short_name}",
|
||||
Method: hws.MethodGET,
|
||||
Handler: handlers.SeasonPage(s, conn),
|
||||
},
|
||||
{
|
||||
Path: "/seasons/{season_short_name}/edit",
|
||||
Method: hws.MethodGET,
|
||||
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditPage(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/seasons/{season_short_name}/edit",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn, audit)),
|
||||
},
|
||||
}
|
||||
|
||||
htmxRoutes := []hws.Route{
|
||||
|
||||
@@ -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
2
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=
|
||||
|
||||
@@ -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
146
internal/db/audit.go
Normal 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
|
||||
@@ -32,14 +32,10 @@ func CreateAuditLog(ctx context.Context, tx bun.Tx, log *AuditLog) error {
|
||||
if log == nil {
|
||||
return errors.New("log cannot be nil")
|
||||
}
|
||||
|
||||
_, err := tx.NewInsert().
|
||||
Model(log).
|
||||
Exec(ctx)
|
||||
err := Insert(tx, log).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewInsert")
|
||||
return errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -2,7 +2,6 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"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,
|
||||
}
|
||||
|
||||
_, err := tx.NewInsert().
|
||||
Model(discordToken).
|
||||
err := Insert(tx, discordToken).
|
||||
On("CONFLICT (discord_id) DO UPDATE").
|
||||
Set("access_token = EXCLUDED.access_token").
|
||||
Set("refresh_token = EXCLUDED.refresh_token").
|
||||
Set("expires_at = EXCLUDED.expires_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewInsert")
|
||||
return errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
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
|
||||
func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
||||
token := new(DiscordToken)
|
||||
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
|
||||
return GetByField[DiscordToken](tx, "discord_id", u.DiscordID).GetFirst(ctx)
|
||||
}
|
||||
|
||||
// Convert reverts the token back into a *discord.Token
|
||||
|
||||
128
internal/db/insert.go
Normal file
128
internal/db/insert.go
Normal 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
|
||||
}
|
||||
@@ -34,20 +34,7 @@ func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permis
|
||||
if name == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
}
|
||||
|
||||
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
|
||||
return GetByField[Permission](tx, "name", name).GetFirst(ctx)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, errors.New("id must be positive")
|
||||
}
|
||||
|
||||
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
|
||||
return GetByID[Permission](tx, id).GetFirst(ctx)
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
return nil, errors.New("resource cannot be empty")
|
||||
}
|
||||
|
||||
perms := []*Permission{}
|
||||
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
|
||||
perms, err := GetByField[[]*Permission](tx, "resource", resource).GetAll(ctx)
|
||||
return *perms, err
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
_, err := tx.NewInsert().
|
||||
Model(perm).
|
||||
err := Insert(tx, perm).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewInsert")
|
||||
return errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -77,12 +77,11 @@ func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
|
||||
}
|
||||
role.CreatedAt = time.Now().Unix()
|
||||
|
||||
_, err := tx.NewInsert().
|
||||
Model(role).
|
||||
err := Insert(tx, role).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewInsert")
|
||||
return errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
_, err := tx.NewUpdate().
|
||||
Model(role).
|
||||
err := Update(tx, role).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewUpdate")
|
||||
return errors.Wrap(err, "db.Update")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -128,12 +126,11 @@ func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID in
|
||||
RoleID: roleID,
|
||||
PermissionID: permissionID,
|
||||
}
|
||||
_, err := tx.NewInsert().
|
||||
Model(rolePerm).
|
||||
err := Insert(tx, rolePerm).
|
||||
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewInsert")
|
||||
return errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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
115
internal/db/update.go
Normal 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
|
||||
}
|
||||
@@ -40,12 +40,11 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
|
||||
DiscordID: discorduser.ID,
|
||||
}
|
||||
|
||||
_, err := tx.NewInsert().
|
||||
Model(user).
|
||||
err := Insert(tx, user).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewInsert")
|
||||
return nil, errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
|
||||
@@ -29,12 +29,11 @@ func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
|
||||
UserID: userID,
|
||||
RoleID: roleID,
|
||||
}
|
||||
_, err := tx.NewInsert().
|
||||
Model(userRole).
|
||||
err := Insert(tx, userRole).
|
||||
On("CONFLICT (user_id, role_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewInsert")
|
||||
return errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -279,6 +279,9 @@
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
.-mt-2 {
|
||||
margin-top: calc(var(--spacing) * -2);
|
||||
}
|
||||
.mt-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)));
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
column-gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -570,6 +580,10 @@
|
||||
.rounded-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-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
@@ -648,6 +662,12 @@
|
||||
.bg-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 {
|
||||
background-color: var(--sapphire);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
103
internal/handlers/season_edit.go
Normal file
103
internal/handlers/season_edit.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"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/validation"
|
||||
@@ -30,6 +31,7 @@ func NewSeason(
|
||||
func NewSeasonSubmit(
|
||||
s *hws.Server,
|
||||
conn *bun.DB,
|
||||
audit *auditlog.Logger,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||
@@ -67,9 +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")
|
||||
return false, errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
@@ -85,9 +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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -27,18 +27,47 @@ package datepicker
|
||||
// The component submits the date in DD/MM/YYYY format. To parse on the server:
|
||||
// date, err := time.Parse("02/01/2006", dateString)
|
||||
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
|
||||
x-data="{
|
||||
x-data={ templ.JSFuncCall("datePickerData", defaultValue).CallInline }
|
||||
>
|
||||
<script>
|
||||
function datePickerData(defaultValue) {
|
||||
return {
|
||||
showPicker: false,
|
||||
selectedDate: '',
|
||||
displayDate: '',
|
||||
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'],
|
||||
months: [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
init() {
|
||||
// Ensure dropdowns are initialized with current month/year
|
||||
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();
|
||||
@@ -47,15 +76,17 @@ templ DatePicker(id, name, label, placeholder string, required bool, onChange st
|
||||
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 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.displayDate = d + "/" + m + "/" + y;
|
||||
this.selectedDate = this.displayDate;
|
||||
$refs.dateInput.value = this.displayDate;
|
||||
this.$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 }));
|
||||
this.$refs.dateInput.dispatchEvent(
|
||||
new Event("input", { bubbles: true }),
|
||||
);
|
||||
},
|
||||
prevMonth() {
|
||||
if (this.tempMonth === 0) {
|
||||
@@ -75,12 +106,15 @@ templ DatePicker(id, name, label, placeholder string, required bool, onChange st
|
||||
},
|
||||
isToday(day) {
|
||||
const today = new Date();
|
||||
return day === today.getDate() &&
|
||||
return (
|
||||
day === today.getDate() &&
|
||||
this.tempMonth === today.getMonth() &&
|
||||
this.tempYear === today.getFullYear();
|
||||
this.tempYear === today.getFullYear()
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
}"
|
||||
>
|
||||
</script>
|
||||
<label for={ id } class="block text-sm font-medium mb-2">{ label }</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
|
||||
@@ -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 "Same 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"
|
||||
|
||||
177
internal/view/seasonsview/edit_form.templ
Normal file
177
internal/view/seasonsview/edit_form.templ
Normal 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")
|
||||
}
|
||||
12
internal/view/seasonsview/edit_page.templ
Normal file
12
internal/view/seasonsview/edit_page.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user