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
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
NEVER BUILD MANUALLY
|
||||||
```bash
|
```bash
|
||||||
# Full production build (tailwind → templ → go generate → go build)
|
# Full production build (tailwind → templ → go generate → go build)
|
||||||
make build
|
make build
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ func setupBun(cfg *config.Config) (conn *bun.DB, close func() error) {
|
|||||||
sqldb.SetConnMaxIdleTime(5 * time.Minute)
|
sqldb.SetConnMaxIdleTime(5 * time.Minute)
|
||||||
|
|
||||||
conn = bun.NewDB(sqldb, pgdialect.New())
|
conn = bun.NewDB(sqldb, pgdialect.New())
|
||||||
|
registerDBModels(conn)
|
||||||
close = sqldb.Close
|
close = sqldb.Close
|
||||||
return conn, close
|
return conn, close
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDBModels(conn *bun.DB) {
|
func registerDBModels(conn *bun.DB) []any {
|
||||||
models := []any{
|
models := []any{
|
||||||
(*db.RolePermission)(nil),
|
(*db.RolePermission)(nil),
|
||||||
(*db.UserRole)(nil),
|
(*db.UserRole)(nil),
|
||||||
@@ -39,4 +40,5 @@ func registerDBModels(conn *bun.DB) {
|
|||||||
(*db.AuditLog)(nil),
|
(*db.AuditLog)(nil),
|
||||||
}
|
}
|
||||||
conn.RegisterModel(models...)
|
conn.RegisterModel(models...)
|
||||||
|
return models
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
|
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
|
||||||
"git.haelnorr.com/h/oslstats/internal/backup"
|
"git.haelnorr.com/h/oslstats/internal/backup"
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
"github.com/uptrace/bun/migrate"
|
"github.com/uptrace/bun/migrate"
|
||||||
@@ -349,10 +348,7 @@ func resetDatabase(ctx context.Context, cfg *config.Config) error {
|
|||||||
conn, close := setupBun(cfg)
|
conn, close := setupBun(cfg)
|
||||||
defer func() { _ = close() }()
|
defer func() { _ = close() }()
|
||||||
|
|
||||||
models := []any{
|
models := registerDBModels(conn)
|
||||||
(*db.User)(nil),
|
|
||||||
(*db.DiscordToken)(nil),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if err := conn.ResetModel(ctx, model); err != nil {
|
if err := conn.ResetModel(ctx, model); err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,78 +142,7 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed system roles
|
err = seedSystemRBAC(ctx, dbConn)
|
||||||
now := time.Now().Unix()
|
|
||||||
|
|
||||||
adminRole := &db.Role{
|
|
||||||
Name: "admin",
|
|
||||||
DisplayName: "Administrator",
|
|
||||||
Description: "Full system access with all permissions",
|
|
||||||
IsSystem: true,
|
|
||||||
CreatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = dbConn.NewInsert().
|
|
||||||
Model(adminRole).
|
|
||||||
Returning("id").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
userRole := &db.Role{
|
|
||||||
Name: "user",
|
|
||||||
DisplayName: "User",
|
|
||||||
Description: "Standard user with basic permissions",
|
|
||||||
IsSystem: true,
|
|
||||||
CreatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = dbConn.NewInsert().
|
|
||||||
Model(userRole).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed system permissions
|
|
||||||
permissionsData := []*db.Permission{
|
|
||||||
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
|
|
||||||
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
|
|
||||||
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
|
|
||||||
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
|
|
||||||
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
|
|
||||||
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", IsSystem: true, CreatedAt: now},
|
|
||||||
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = dbConn.NewInsert().
|
|
||||||
Model(&permissionsData).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant wildcard permission to admin role using Bun
|
|
||||||
// First, get the IDs
|
|
||||||
var wildcardPerm db.Permission
|
|
||||||
err = dbConn.NewSelect().
|
|
||||||
Model(&wildcardPerm).
|
|
||||||
Where("name = ?", "*").
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert role_permission mapping
|
|
||||||
adminRolePerms := &db.RolePermission{
|
|
||||||
RoleID: adminRole.ID,
|
|
||||||
PermissionID: wildcardPerm.ID,
|
|
||||||
}
|
|
||||||
_, err = dbConn.NewInsert().
|
|
||||||
Model(adminRolePerms).
|
|
||||||
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -242,3 +172,82 @@ func init() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
|
||||||
|
// Seed system roles
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
adminRole := &db.Role{
|
||||||
|
Name: "admin",
|
||||||
|
DisplayName: "Administrator",
|
||||||
|
Description: "Full system access with all permissions",
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := dbConn.NewInsert().
|
||||||
|
Model(adminRole).
|
||||||
|
Returning("id").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "dbConn.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
userRole := &db.Role{
|
||||||
|
Name: "user",
|
||||||
|
DisplayName: "User",
|
||||||
|
Description: "Standard user with basic permissions",
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(userRole).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "dbConn.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed system permissions
|
||||||
|
permissionsData := []*db.Permission{
|
||||||
|
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(&permissionsData).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "dbConn.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant wildcard permission to admin role using Bun
|
||||||
|
// First, get the IDs
|
||||||
|
var wildcardPerm db.Permission
|
||||||
|
err = dbConn.NewSelect().
|
||||||
|
Model(&wildcardPerm).
|
||||||
|
Where("name = ?", "*").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert role_permission mapping
|
||||||
|
adminRolePerms := &db.RolePermission{
|
||||||
|
RoleID: adminRole.ID,
|
||||||
|
PermissionID: wildcardPerm.ID,
|
||||||
|
}
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(adminRolePerms).
|
||||||
|
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "dbConn.NewInsert")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
)
|
)
|
||||||
@@ -83,18 +84,28 @@ func addRoutes(
|
|||||||
{
|
{
|
||||||
Path: "/seasons/new",
|
Path: "/seasons/new",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
Handler: handlers.NewSeason(s, conn),
|
Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeason(s, conn)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/new",
|
Path: "/seasons/new",
|
||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: handlers.NewSeasonSubmit(s, conn),
|
Handler: perms.RequirePermission(s, permissions.SeasonsCreate)(handlers.NewSeasonSubmit(s, conn, audit)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons/{season_short_name}",
|
Path: "/seasons/{season_short_name}",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
Handler: handlers.SeasonPage(s, conn),
|
Handler: handlers.SeasonPage(s, conn),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/edit",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditPage(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/edit",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.SeasonsUpdate)(handlers.SeasonEditSubmit(s, conn, audit)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
htmxRoutes := []hws.Route{
|
htmxRoutes := []hws.Route{
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
|
|||||||
logger.Debug().Msg("Config loaded and logger started")
|
logger.Debug().Msg("Config loaded and logger started")
|
||||||
logger.Debug().Msg("Connecting to database")
|
logger.Debug().Msg("Connecting to database")
|
||||||
bun, closedb := setupBun(cfg)
|
bun, closedb := setupBun(cfg)
|
||||||
registerDBModels(bun)
|
// registerDBModels(bun)
|
||||||
|
|
||||||
// Setup embedded files
|
// Setup embedded files
|
||||||
logger.Debug().Msg("Getting embedded files")
|
logger.Debug().Msg("Getting embedded files")
|
||||||
|
|||||||
2
go.sum
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/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
||||||
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
|
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
|
||||||
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
|
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.6.0 h1:qaRsgfcD7M6xCasfXwP6Ww9RM4TwDqYMFK2YtO6nt6c=
|
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.6.0/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
|
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0=
|
git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0=
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
|
git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
|
||||||
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
||||||
|
|||||||
@@ -156,3 +156,32 @@ func (l *Logger) CleanupOldLogs(ctx context.Context, daysToKeep int) (int, error
|
|||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Callback returns a db.AuditCallback that logs to this Logger
|
||||||
|
// This is used with the generic database helpers (Insert, Update, Delete)
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// audit := auditlog.NewLogger(conn)
|
||||||
|
// err := db.Insert(tx, season).
|
||||||
|
// WithAudit(r, audit.Callback()).
|
||||||
|
// Exec(ctx)
|
||||||
|
func (l *Logger) Callback() db.AuditCallback {
|
||||||
|
return func(ctx context.Context, tx bun.Tx, info *db.AuditInfo, r *http.Request) error {
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("no user in context for audit logging")
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.LogSuccess(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
user,
|
||||||
|
info.Action,
|
||||||
|
info.ResourceType,
|
||||||
|
info.ResourceID,
|
||||||
|
info.Details,
|
||||||
|
r,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
146
internal/db/audit.go
Normal file
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 {
|
if log == nil {
|
||||||
return errors.New("log cannot be nil")
|
return errors.New("log cannot be nil")
|
||||||
}
|
}
|
||||||
|
err := Insert(tx, log).Exec(ctx)
|
||||||
_, err := tx.NewInsert().
|
|
||||||
Model(log).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deleter[T any] struct {
|
type deleter[T any] struct {
|
||||||
|
tx bun.Tx
|
||||||
q *bun.DeleteQuery
|
q *bun.DeleteQuery
|
||||||
|
resourceID any // Store ID before deletion for audit
|
||||||
|
auditCallback AuditCallback
|
||||||
|
auditRequest *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
type systemType interface {
|
type systemType interface {
|
||||||
@@ -18,13 +23,27 @@ type systemType interface {
|
|||||||
|
|
||||||
func DeleteItem[T any](tx bun.Tx) *deleter[T] {
|
func DeleteItem[T any](tx bun.Tx) *deleter[T] {
|
||||||
return &deleter[T]{
|
return &deleter[T]{
|
||||||
tx.NewDelete().
|
tx: tx,
|
||||||
|
q: tx.NewDelete().
|
||||||
Model((*T)(nil)),
|
Model((*T)(nil)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
|
func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
|
||||||
d.q = d.q.Where(query, args...)
|
d.q = d.q.Where(query, args...)
|
||||||
|
// Try to capture resource ID from WHERE clause if it's a simple "id = ?" pattern
|
||||||
|
if query == "id = ?" && len(args) > 0 {
|
||||||
|
d.resourceID = args[0]
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAudit enables audit logging for this delete operation
|
||||||
|
// The callback will be invoked after successful deletion with auto-generated audit info
|
||||||
|
// If the callback returns an error, the transaction will be rolled back
|
||||||
|
func (d *deleter[T]) WithAudit(r *http.Request, callback AuditCallback) *deleter[T] {
|
||||||
|
d.auditRequest = r
|
||||||
|
d.auditCallback = callback
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +53,29 @@ func (d *deleter[T]) Delete(ctx context.Context) error {
|
|||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return errors.Wrap(err, "bun.DeleteQuery.Exec")
|
return errors.Wrap(err, "bun.DeleteQuery.Exec")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle audit logging if enabled
|
||||||
|
if d.auditCallback != nil && d.auditRequest != nil {
|
||||||
|
tableName := extractTableName[T]()
|
||||||
|
resourceType := extractResourceType(tableName)
|
||||||
|
action := buildAction(resourceType, "delete")
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: d.resourceID,
|
||||||
|
Details: nil, // Delete doesn't need details
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call audit callback - if it fails, return error to trigger rollback
|
||||||
|
if err := d.auditCallback(ctx, d.tx, info, d.auditRequest); err != nil {
|
||||||
|
return errors.Wrap(err, "audit.callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {
|
func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
@@ -38,15 +37,14 @@ func (u *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord
|
|||||||
TokenType: token.TokenType,
|
TokenType: token.TokenType,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := tx.NewInsert().
|
err := Insert(tx, discordToken).
|
||||||
Model(discordToken).
|
|
||||||
On("CONFLICT (discord_id) DO UPDATE").
|
On("CONFLICT (discord_id) DO UPDATE").
|
||||||
Set("access_token = EXCLUDED.access_token").
|
Set("access_token = EXCLUDED.access_token").
|
||||||
Set("refresh_token = EXCLUDED.refresh_token").
|
Set("refresh_token = EXCLUDED.refresh_token").
|
||||||
Set("expires_at = EXCLUDED.expires_at").
|
Set("expires_at = EXCLUDED.expires_at").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -73,19 +71,7 @@ func (u *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToke
|
|||||||
|
|
||||||
// GetDiscordToken retrieves the users discord token from the database
|
// GetDiscordToken retrieves the users discord token from the database
|
||||||
func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
||||||
token := new(DiscordToken)
|
return GetByField[DiscordToken](tx, "discord_id", u.DiscordID).GetFirst(ctx)
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(token).
|
|
||||||
Where("discord_id = ?", u.DiscordID).
|
|
||||||
Limit(1).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert reverts the token back into a *discord.Token
|
// Convert reverts the token back into a *discord.Token
|
||||||
|
|||||||
128
internal/db/insert.go
Normal file
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 == "" {
|
if name == "" {
|
||||||
return nil, errors.New("name cannot be empty")
|
return nil, errors.New("name cannot be empty")
|
||||||
}
|
}
|
||||||
|
return GetByField[Permission](tx, "name", name).GetFirst(ctx)
|
||||||
perm := new(Permission)
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(perm).
|
|
||||||
Where("name = ?", name).
|
|
||||||
Limit(1).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return perm, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPermissionByID queries the database for a permission matching the given ID
|
// GetPermissionByID queries the database for a permission matching the given ID
|
||||||
@@ -56,20 +43,7 @@ func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, err
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return nil, errors.New("id must be positive")
|
return nil, errors.New("id must be positive")
|
||||||
}
|
}
|
||||||
|
return GetByID[Permission](tx, id).GetFirst(ctx)
|
||||||
perm := new(Permission)
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(perm).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Limit(1).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return perm, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPermissionsByResource queries for all permissions for a given resource
|
// GetPermissionsByResource queries for all permissions for a given resource
|
||||||
@@ -77,34 +51,8 @@ func GetPermissionsByResource(ctx context.Context, tx bun.Tx, resource string) (
|
|||||||
if resource == "" {
|
if resource == "" {
|
||||||
return nil, errors.New("resource cannot be empty")
|
return nil, errors.New("resource cannot be empty")
|
||||||
}
|
}
|
||||||
|
perms, err := GetByField[[]*Permission](tx, "resource", resource).GetAll(ctx)
|
||||||
perms := []*Permission{}
|
return *perms, err
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(&perms).
|
|
||||||
Where("resource = ?", resource).
|
|
||||||
Order("action ASC").
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return perms, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPermissionsByIDs queries for permissions matching the given IDs
|
|
||||||
func GetPermissionsByIDs(ctx context.Context, tx bun.Tx, ids []int) ([]*Permission, error) {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return []*Permission{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var perms []*Permission
|
|
||||||
err := tx.NewSelect().
|
|
||||||
Model(&perms).
|
|
||||||
Where("id IN (?)", bun.In(ids)).
|
|
||||||
Scan(ctx)
|
|
||||||
if err != nil && errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
||||||
}
|
|
||||||
return perms, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAllPermissions returns all permissions
|
// ListAllPermissions returns all permissions
|
||||||
@@ -138,12 +86,11 @@ func CreatePermission(ctx context.Context, tx bun.Tx, perm *Permission) error {
|
|||||||
return errors.New("action cannot be empty")
|
return errors.New("action cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := tx.NewInsert().
|
err := Insert(tx, perm).
|
||||||
Model(perm).
|
|
||||||
Returning("id").
|
Returning("id").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -77,12 +77,11 @@ func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
|
|||||||
}
|
}
|
||||||
role.CreatedAt = time.Now().Unix()
|
role.CreatedAt = time.Now().Unix()
|
||||||
|
|
||||||
_, err := tx.NewInsert().
|
err := Insert(tx, role).
|
||||||
Model(role).
|
|
||||||
Returning("id").
|
Returning("id").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -97,12 +96,11 @@ func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
|
|||||||
return errors.New("role id must be positive")
|
return errors.New("role id must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := tx.NewUpdate().
|
err := Update(tx, role).
|
||||||
Model(role).
|
|
||||||
WherePK().
|
WherePK().
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewUpdate")
|
return errors.Wrap(err, "db.Update")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -128,12 +126,11 @@ func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID in
|
|||||||
RoleID: roleID,
|
RoleID: roleID,
|
||||||
PermissionID: permissionID,
|
PermissionID: permissionID,
|
||||||
}
|
}
|
||||||
_, err := tx.NewInsert().
|
err := Insert(tx, rolePerm).
|
||||||
Model(rolePerm).
|
|
||||||
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -13,34 +13,22 @@ type Season struct {
|
|||||||
bun.BaseModel `bun:"table:seasons,alias:s"`
|
bun.BaseModel `bun:"table:seasons,alias:s"`
|
||||||
|
|
||||||
ID int `bun:"id,pk,autoincrement"`
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
Name string `bun:"name,unique"`
|
Name string `bun:"name,unique,notnull"`
|
||||||
ShortName string `bun:"short_name,unique"`
|
ShortName string `bun:"short_name,unique,notnull"`
|
||||||
StartDate time.Time `bun:"start_date,notnull"`
|
StartDate time.Time `bun:"start_date,notnull"`
|
||||||
EndDate bun.NullTime `bun:"end_date"`
|
EndDate bun.NullTime `bun:"end_date"`
|
||||||
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
|
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
|
||||||
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
|
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) {
|
// NewSeason returns a new season. It does not add it to the database
|
||||||
if name == "" {
|
func NewSeason(name, shortname string, start time.Time) *Season {
|
||||||
return nil, errors.New("name cannot be empty")
|
|
||||||
}
|
|
||||||
if shortname == "" {
|
|
||||||
return nil, errors.New("shortname cannot be empty")
|
|
||||||
}
|
|
||||||
season := &Season{
|
season := &Season{
|
||||||
Name: name,
|
Name: name,
|
||||||
ShortName: strings.ToUpper(shortname),
|
ShortName: strings.ToUpper(shortname),
|
||||||
StartDate: start.Truncate(time.Hour * 24),
|
StartDate: start.Truncate(time.Hour * 24),
|
||||||
}
|
}
|
||||||
_, err := tx.NewInsert().
|
return season
|
||||||
Model(season).
|
|
||||||
Returning("id").
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "tx.NewInsert")
|
|
||||||
}
|
|
||||||
return season, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Season], error) {
|
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Season], error) {
|
||||||
@@ -59,3 +47,17 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error
|
|||||||
}
|
}
|
||||||
return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx)
|
return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update updates the season struct. It does not insert to the database
|
||||||
|
func (s *Season) Update(start, end, finalsStart, finalsEnd time.Time) {
|
||||||
|
s.StartDate = start.Truncate(time.Hour * 24)
|
||||||
|
if !end.IsZero() {
|
||||||
|
s.EndDate.Time = end.Truncate(time.Hour * 24)
|
||||||
|
}
|
||||||
|
if !finalsStart.IsZero() {
|
||||||
|
s.FinalsStartDate.Time = finalsStart.Truncate(time.Hour * 24)
|
||||||
|
}
|
||||||
|
if !finalsEnd.IsZero() {
|
||||||
|
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
115
internal/db/update.go
Normal file
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,
|
DiscordID: discorduser.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := tx.NewInsert().
|
err := Insert(tx, user).
|
||||||
Model(user).
|
|
||||||
Returning("id").
|
Returning("id").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "tx.NewInsert")
|
return nil, errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
|
|||||||
@@ -29,12 +29,11 @@ func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
RoleID: roleID,
|
RoleID: roleID,
|
||||||
}
|
}
|
||||||
_, err := tx.NewInsert().
|
err := Insert(tx, userRole).
|
||||||
Model(userRole).
|
|
||||||
On("CONFLICT (user_id, role_id) DO NOTHING").
|
On("CONFLICT (user_id, role_id) DO NOTHING").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -279,6 +279,9 @@
|
|||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
.-mt-2 {
|
||||||
|
margin-top: calc(var(--spacing) * -2);
|
||||||
|
}
|
||||||
.mt-1 {
|
.mt-1 {
|
||||||
margin-top: calc(var(--spacing) * 1);
|
margin-top: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@@ -523,6 +526,13 @@
|
|||||||
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
|
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.space-y-4 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.gap-x-2 {
|
.gap-x-2 {
|
||||||
column-gap: calc(var(--spacing) * 2);
|
column-gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -570,6 +580,10 @@
|
|||||||
.rounded-xl {
|
.rounded-xl {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
}
|
}
|
||||||
|
.rounded-t-lg {
|
||||||
|
border-top-left-radius: var(--radius-lg);
|
||||||
|
border-top-right-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
.border {
|
.border {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -648,6 +662,12 @@
|
|||||||
.bg-mauve {
|
.bg-mauve {
|
||||||
background-color: var(--mauve);
|
background-color: var(--mauve);
|
||||||
}
|
}
|
||||||
|
.bg-red\/10 {
|
||||||
|
background-color: var(--red);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--red) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-sapphire {
|
.bg-sapphire {
|
||||||
background-color: var(--sapphire);
|
background-color: var(--sapphire);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ func NotificationWS(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logError(s, "Notification error", errors.Wrap(err, "notifyLoop"))
|
logError(s, "Notification error", errors.Wrap(err, "notifyLoop"))
|
||||||
}
|
}
|
||||||
err = ws.CloseNow()
|
_ = ws.CloseNow()
|
||||||
if err != nil {
|
|
||||||
logError(s, "Error closing websocket", errors.Wrap(err, "ws.CloseNow"))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ func Register(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.UpdateDiscordToken")
|
return false, errors.Wrap(err, "db.UpdateDiscordToken")
|
||||||
}
|
}
|
||||||
|
if shouldGrantAdmin(user, cfg.RBAC) {
|
||||||
|
err := ensureUserHasAdminRole(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "ensureUserHasAdminRole")
|
||||||
|
}
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
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"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
@@ -30,6 +31,7 @@ func NewSeason(
|
|||||||
func NewSeasonSubmit(
|
func NewSeasonSubmit(
|
||||||
s *hws.Server,
|
s *hws.Server,
|
||||||
conn *bun.DB,
|
conn *bun.DB,
|
||||||
|
audit *auditlog.Logger,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
@@ -67,9 +69,10 @@ func NewSeasonSubmit(
|
|||||||
if !nameUnique || !shortNameUnique {
|
if !nameUnique || !shortNameUnique {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
season, err = db.NewSeason(ctx, tx, name, shortName, startDate)
|
season = db.NewSeason(name, shortName, startDate)
|
||||||
|
err = db.Insert(tx, season).WithAudit(r, audit.Callback()).Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.NewSeason")
|
return false, errors.Wrap(err, "db.Insert")
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
@@ -85,9 +88,8 @@ func NewSeasonSubmit(
|
|||||||
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
|
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
|
|
||||||
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
|
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package notify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
"git.haelnorr.com/h/golib/notify"
|
"git.haelnorr.com/h/golib/notify"
|
||||||
@@ -61,3 +62,11 @@ func Info(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg stri
|
|||||||
func Success(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
|
func Success(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
|
||||||
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
|
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuccessWithDelay notifies with success level and a short delay to account for redirects
|
||||||
|
func SuccessWithDelay(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
|
||||||
|
go func() {
|
||||||
|
<-time.Tick(1 * time.Second)
|
||||||
|
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,18 +27,47 @@ package datepicker
|
|||||||
// The component submits the date in DD/MM/YYYY format. To parse on the server:
|
// The component submits the date in DD/MM/YYYY format. To parse on the server:
|
||||||
// date, err := time.Parse("02/01/2006", dateString)
|
// date, err := time.Parse("02/01/2006", dateString)
|
||||||
templ DatePicker(id, name, label, placeholder string, required bool, onChange string) {
|
templ DatePicker(id, name, label, placeholder string, required bool, onChange string) {
|
||||||
|
@DatePickerWithDefault(id, name, label, placeholder, required, onChange, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatePickerWithDefault is the same as DatePicker but accepts a default value in DD/MM/YYYY format
|
||||||
|
templ DatePickerWithDefault(id, name, label, placeholder string, required bool, onChange, defaultValue string) {
|
||||||
<div
|
<div
|
||||||
x-data="{
|
x-data={ templ.JSFuncCall("datePickerData", defaultValue).CallInline }
|
||||||
|
>
|
||||||
|
<script>
|
||||||
|
function datePickerData(defaultValue) {
|
||||||
|
return {
|
||||||
showPicker: false,
|
showPicker: false,
|
||||||
selectedDate: '',
|
selectedDate: defaultValue || "",
|
||||||
displayDate: '',
|
displayDate: defaultValue || "",
|
||||||
tempYear: new Date().getFullYear(),
|
tempYear: new Date().getFullYear(),
|
||||||
tempMonth: new Date().getMonth(),
|
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() {
|
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.tempYear = new Date().getFullYear();
|
||||||
this.tempMonth = new Date().getMonth();
|
this.tempMonth = new Date().getMonth();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
getDaysInMonth(year, month) {
|
getDaysInMonth(year, month) {
|
||||||
return new Date(year, month + 1, 0).getDate();
|
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();
|
return new Date(year, month, 1).getDay();
|
||||||
},
|
},
|
||||||
selectDate(day) {
|
selectDate(day) {
|
||||||
const d = String(day).padStart(2, '0');
|
const d = String(day).padStart(2, "0");
|
||||||
const m = String(this.tempMonth + 1).padStart(2, '0');
|
const m = String(this.tempMonth + 1).padStart(2, "0");
|
||||||
const y = this.tempYear;
|
const y = this.tempYear;
|
||||||
this.displayDate = d + '/' + m + '/' + y;
|
this.displayDate = d + "/" + m + "/" + y;
|
||||||
this.selectedDate = this.displayDate;
|
this.selectedDate = this.displayDate;
|
||||||
$refs.dateInput.value = this.displayDate;
|
this.$refs.dateInput.value = this.displayDate;
|
||||||
this.showPicker = false;
|
this.showPicker = false;
|
||||||
// Manually trigger input event so Alpine.js @input handler fires
|
// 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() {
|
prevMonth() {
|
||||||
if (this.tempMonth === 0) {
|
if (this.tempMonth === 0) {
|
||||||
@@ -75,12 +106,15 @@ templ DatePicker(id, name, label, placeholder string, required bool, onChange st
|
|||||||
},
|
},
|
||||||
isToday(day) {
|
isToday(day) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
return day === today.getDate() &&
|
return (
|
||||||
|
day === today.getDate() &&
|
||||||
this.tempMonth === today.getMonth() &&
|
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>
|
<label for={ id } class="block text-sm font-medium mb-2">{ label }</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -144,10 +144,8 @@ func formatDateLong(t time.Time) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatDuration(start, end time.Time) string {
|
func formatDuration(start, end time.Time) string {
|
||||||
days := int(end.Sub(start).Hours() / 24)
|
days := int(end.Sub(start).Hours()/24) + 1
|
||||||
if days == 0 {
|
if days == 1 {
|
||||||
return "Same day"
|
|
||||||
} else if days == 1 {
|
|
||||||
return "1 day"
|
return "1 day"
|
||||||
} else if days < 7 {
|
} else if days < 7 {
|
||||||
return strconv.Itoa(days) + " days"
|
return strconv.Itoa(days) + " days"
|
||||||
|
|||||||
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