fixed some migration issues and added generics for update and insert
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,13 +3,18 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deleter[T any] struct {
|
type deleter[T any] struct {
|
||||||
q *bun.DeleteQuery
|
tx bun.Tx
|
||||||
|
q *bun.DeleteQuery
|
||||||
|
resourceID any // Store ID before deletion for audit
|
||||||
|
auditCallback AuditCallback
|
||||||
|
auditRequest *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
type systemType interface {
|
type systemType interface {
|
||||||
@@ -18,13 +23,27 @@ type systemType interface {
|
|||||||
|
|
||||||
func DeleteItem[T any](tx bun.Tx) *deleter[T] {
|
func DeleteItem[T any](tx bun.Tx) *deleter[T] {
|
||||||
return &deleter[T]{
|
return &deleter[T]{
|
||||||
tx.NewDelete().
|
tx: tx,
|
||||||
|
q: tx.NewDelete().
|
||||||
Model((*T)(nil)),
|
Model((*T)(nil)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
|
func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
|
||||||
d.q = d.q.Where(query, args...)
|
d.q = d.q.Where(query, args...)
|
||||||
|
// Try to capture resource ID from WHERE clause if it's a simple "id = ?" pattern
|
||||||
|
if query == "id = ?" && len(args) > 0 {
|
||||||
|
d.resourceID = args[0]
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAudit enables audit logging for this delete operation
|
||||||
|
// The callback will be invoked after successful deletion with auto-generated audit info
|
||||||
|
// If the callback returns an error, the transaction will be rolled back
|
||||||
|
func (d *deleter[T]) WithAudit(r *http.Request, callback AuditCallback) *deleter[T] {
|
||||||
|
d.auditRequest = r
|
||||||
|
d.auditCallback = callback
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +53,29 @@ func (d *deleter[T]) Delete(ctx context.Context) error {
|
|||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return errors.Wrap(err, "bun.DeleteQuery.Exec")
|
||||||
}
|
}
|
||||||
return errors.Wrap(err, "bun.DeleteQuery.Exec")
|
|
||||||
|
// Handle audit logging if enabled
|
||||||
|
if d.auditCallback != nil && d.auditRequest != nil {
|
||||||
|
tableName := extractTableName[T]()
|
||||||
|
resourceType := extractResourceType(tableName)
|
||||||
|
action := buildAction(resourceType, "delete")
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: d.resourceID,
|
||||||
|
Details: nil, // Delete doesn't need details
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call audit callback - if it fails, return error to trigger rollback
|
||||||
|
if err := d.auditCallback(ctx, d.tx, info, d.auditRequest); err != nil {
|
||||||
|
return errors.Wrap(err, "audit.callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {
|
func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConflict adds conflict handling for upserts
|
||||||
|
// Example: .OnConflict("(discord_id) DO UPDATE")
|
||||||
|
func (i *inserter[T]) OnConflict(query string) *inserter[T] {
|
||||||
|
i.q = i.q.On(query)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set adds a SET clause for upserts (use with OnConflict)
|
||||||
|
// Example: .Set("access_token = EXCLUDED.access_token")
|
||||||
|
func (i *inserter[T]) Set(query string, args ...any) *inserter[T] {
|
||||||
|
i.q = i.q.Set(query, args...)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returning overrides the default Returning("*") clause
|
||||||
|
// Example: .Returning("id", "created_at")
|
||||||
|
func (i *inserter[T]) Returning(columns ...string) *inserter[T] {
|
||||||
|
if len(columns) == 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
// Build column list as single string
|
||||||
|
columnList := strings.Join(columns, ", ")
|
||||||
|
i.q = i.q.Returning(columnList)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAudit enables audit logging for this insert operation
|
||||||
|
// The callback will be invoked after successful insert with auto-generated audit info
|
||||||
|
// If the callback returns an error, the transaction will be rolled back
|
||||||
|
func (i *inserter[T]) WithAudit(r *http.Request, callback AuditCallback) *inserter[T] {
|
||||||
|
i.auditRequest = r
|
||||||
|
i.auditCallback = callback
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec executes the insert and optionally logs to audit
|
||||||
|
// Returns an error if insert fails or if audit callback fails (triggering rollback)
|
||||||
|
func (i *inserter[T]) Exec(ctx context.Context) error {
|
||||||
|
// Execute insert
|
||||||
|
_, err := i.q.Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "bun.InsertQuery.Exec")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle audit logging if enabled
|
||||||
|
if i.auditCallback != nil && i.auditRequest != nil {
|
||||||
|
tableName := extractTableName[T]()
|
||||||
|
resourceType := extractResourceType(tableName)
|
||||||
|
action := buildAction(resourceType, "create")
|
||||||
|
|
||||||
|
var info *AuditInfo
|
||||||
|
if i.isBulk {
|
||||||
|
// For bulk inserts, log once with count in details
|
||||||
|
info = &AuditInfo{
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: nil,
|
||||||
|
Details: map[string]any{
|
||||||
|
"count": len(i.models),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For single insert, log with resource ID
|
||||||
|
info = &AuditInfo{
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: extractPrimaryKey(i.model),
|
||||||
|
Details: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call audit callback - if it fails, return error to trigger rollback
|
||||||
|
if err := i.auditCallback(ctx, i.tx, info, i.auditRequest); err != nil {
|
||||||
|
return errors.Wrap(err, "audit.callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -60,31 +48,16 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error
|
|||||||
return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx)
|
return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateSeason(ctx context.Context, tx bun.Tx, season *Season) error {
|
// Update updates the season struct. It does not insert to the database
|
||||||
if season == nil {
|
func (s *Season) Update(start, end, finalsStart, finalsEnd time.Time) {
|
||||||
return errors.New("season cannot be nil")
|
s.StartDate = start.Truncate(time.Hour * 24)
|
||||||
|
if !end.IsZero() {
|
||||||
|
s.EndDate.Time = end.Truncate(time.Hour * 24)
|
||||||
}
|
}
|
||||||
if season.ID == 0 {
|
if !finalsStart.IsZero() {
|
||||||
return errors.New("season ID cannot be 0")
|
s.FinalsStartDate.Time = finalsStart.Truncate(time.Hour * 24)
|
||||||
}
|
}
|
||||||
// Truncate dates to day precision
|
if !finalsEnd.IsZero() {
|
||||||
season.StartDate = season.StartDate.Truncate(time.Hour * 24)
|
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24)
|
||||||
if !season.EndDate.IsZero() {
|
|
||||||
season.EndDate.Time = season.EndDate.Time.Truncate(time.Hour * 24)
|
|
||||||
}
|
}
|
||||||
if !season.FinalsStartDate.IsZero() {
|
|
||||||
season.FinalsStartDate.Time = season.FinalsStartDate.Time.Truncate(time.Hour * 24)
|
|
||||||
}
|
|
||||||
if !season.FinalsEndDate.IsZero() {
|
|
||||||
season.FinalsEndDate.Time = season.FinalsEndDate.Time.Truncate(time.Hour * 24)
|
|
||||||
}
|
|
||||||
_, err := tx.NewUpdate().
|
|
||||||
Model(season).
|
|
||||||
Column("start_date", "end_date", "finals_start_date", "finals_end_date").
|
|
||||||
Where("id = ?", season.ID).
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "tx.NewUpdate")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
115
internal/db/update.go
Normal file
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
|
||||||
|
}
|
||||||
@@ -45,36 +45,6 @@
|
|||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
--default-mono-font-family: var(--font-mono);
|
--default-mono-font-family: var(--font-mono);
|
||||||
--color-rosewater: var(--rosewater);
|
|
||||||
--color-flamingo: var(--flamingo);
|
|
||||||
--color-pink: var(--pink);
|
|
||||||
--color-mauve: var(--mauve);
|
|
||||||
--color-red: var(--red);
|
|
||||||
--color-dark-red: var(--dark-red);
|
|
||||||
--color-maroon: var(--maroon);
|
|
||||||
--color-peach: var(--peach);
|
|
||||||
--color-yellow: var(--yellow);
|
|
||||||
--color-dark-yellow: var(--dark-yellow);
|
|
||||||
--color-green: var(--green);
|
|
||||||
--color-dark-green: var(--dark-green);
|
|
||||||
--color-teal: var(--teal);
|
|
||||||
--color-sky: var(--sky);
|
|
||||||
--color-sapphire: var(--sapphire);
|
|
||||||
--color-blue: var(--blue);
|
|
||||||
--color-dark-blue: var(--dark-blue);
|
|
||||||
--color-lavender: var(--lavender);
|
|
||||||
--color-text: var(--text);
|
|
||||||
--color-subtext1: var(--subtext1);
|
|
||||||
--color-subtext0: var(--subtext0);
|
|
||||||
--color-overlay2: var(--overlay2);
|
|
||||||
--color-overlay1: var(--overlay1);
|
|
||||||
--color-overlay0: var(--overlay0);
|
|
||||||
--color-surface2: var(--surface2);
|
|
||||||
--color-surface1: var(--surface1);
|
|
||||||
--color-surface0: var(--surface0);
|
|
||||||
--color-base: var(--base);
|
|
||||||
--color-mantle: var(--mantle);
|
|
||||||
--color-crust: var(--crust);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -273,9 +243,6 @@
|
|||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
.top-1 {
|
|
||||||
top: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: calc(1/2 * 100%);
|
top: calc(1/2 * 100%);
|
||||||
}
|
}
|
||||||
@@ -309,24 +276,6 @@
|
|||||||
.z-50 {
|
.z-50 {
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
@media (width >= 40rem) {
|
|
||||||
max-width: 40rem;
|
|
||||||
}
|
|
||||||
@media (width >= 48rem) {
|
|
||||||
max-width: 48rem;
|
|
||||||
}
|
|
||||||
@media (width >= 64rem) {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
@media (width >= 80rem) {
|
|
||||||
max-width: 80rem;
|
|
||||||
}
|
|
||||||
@media (width >= 96rem) {
|
|
||||||
max-width: 96rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -496,25 +445,9 @@
|
|||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.flex-shrink {
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.flex-grow {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
.grow {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
.border-collapse {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.-translate-y-1 {
|
|
||||||
--tw-translate-y: calc(var(--spacing) * -1);
|
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
|
||||||
}
|
|
||||||
.-translate-y-1\/2 {
|
.-translate-y-1\/2 {
|
||||||
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
|
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -623,11 +556,6 @@
|
|||||||
border-color: var(--surface2);
|
border-color: var(--surface2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.truncate {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.overflow-hidden {
|
.overflow-hidden {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -734,9 +662,6 @@
|
|||||||
.bg-mauve {
|
.bg-mauve {
|
||||||
background-color: var(--mauve);
|
background-color: var(--mauve);
|
||||||
}
|
}
|
||||||
.bg-red {
|
|
||||||
background-color: var(--red);
|
|
||||||
}
|
|
||||||
.bg-red\/10 {
|
.bg-red\/10 {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -933,9 +858,6 @@
|
|||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.underline {
|
|
||||||
text-decoration-line: underline;
|
|
||||||
}
|
|
||||||
.opacity-50 {
|
.opacity-50 {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
}
|
}
|
||||||
@@ -951,10 +873,6 @@
|
|||||||
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
}
|
}
|
||||||
.outline {
|
|
||||||
outline-style: var(--tw-outline-style);
|
|
||||||
outline-width: 1px;
|
|
||||||
}
|
|
||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
}
|
}
|
||||||
@@ -1539,11 +1457,6 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0 0 #0000;
|
initial-value: 0 0 #0000;
|
||||||
}
|
}
|
||||||
@property --tw-outline-style {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: solid;
|
|
||||||
}
|
|
||||||
@property --tw-blur {
|
@property --tw-blur {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -1628,7 +1541,6 @@
|
|||||||
--tw-ring-offset-width: 0px;
|
--tw-ring-offset-width: 0px;
|
||||||
--tw-ring-offset-color: #fff;
|
--tw-ring-offset-color: #fff;
|
||||||
--tw-ring-offset-shadow: 0 0 #0000;
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
--tw-outline-style: solid;
|
|
||||||
--tw-blur: initial;
|
--tw-blur: initial;
|
||||||
--tw-brightness: initial;
|
--tw-brightness: initial;
|
||||||
--tw-contrast: initial;
|
--tw-contrast: initial;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
"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/permissions"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
@@ -78,36 +77,14 @@ func SeasonEditSubmit(
|
|||||||
return false, errors.Wrap(err, "db.GetSeason")
|
return false, errors.Wrap(err, "db.GetSeason")
|
||||||
}
|
}
|
||||||
if season == nil {
|
if season == nil {
|
||||||
return true, nil
|
return false, errors.New("season does not exist")
|
||||||
}
|
}
|
||||||
|
season.Update(startDate, endDate, finalsStartDate, finalsEndDate)
|
||||||
// Update only the date fields
|
err = db.Update(tx, season).WherePK().
|
||||||
season.StartDate = startDate
|
Column("start_date", "end_date", "finals_start_date", "finals_end_date").
|
||||||
if !endDate.IsZero() {
|
WithAudit(r, audit.Callback()).Exec(ctx)
|
||||||
season.EndDate = bun.NullTime{Time: endDate}
|
|
||||||
} else {
|
|
||||||
season.EndDate = bun.NullTime{}
|
|
||||||
}
|
|
||||||
if !finalsStartDate.IsZero() {
|
|
||||||
season.FinalsStartDate = bun.NullTime{Time: finalsStartDate}
|
|
||||||
} else {
|
|
||||||
season.FinalsStartDate = bun.NullTime{}
|
|
||||||
}
|
|
||||||
if !finalsEndDate.IsZero() {
|
|
||||||
season.FinalsEndDate = bun.NullTime{Time: finalsEndDate}
|
|
||||||
} else {
|
|
||||||
season.FinalsEndDate = bun.NullTime{}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.UpdateSeason(ctx, tx, season)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.UpdateSeason")
|
return false, errors.Wrap(err, "db.Update")
|
||||||
}
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(),
|
|
||||||
"season", season.ID, nil, r)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "audit.LogSuccess")
|
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
@@ -119,8 +96,8 @@ func SeasonEditSubmit(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notify.Success(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
|
|
||||||
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
|
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
notify.SuccessWithDelay(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
"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/permissions"
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
"git.haelnorr.com/h/timefmt"
|
"git.haelnorr.com/h/timefmt"
|
||||||
@@ -70,15 +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")
|
||||||
}
|
|
||||||
user := db.CurrentUser(ctx)
|
|
||||||
err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(),
|
|
||||||
"season", season.ID, nil, r)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.Wrap(err, "audit.LogSuccess")
|
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
@@ -94,8 +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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 "1 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user