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
|
||||
|
||||
### Building
|
||||
NEVER BUILD MANUALLY
|
||||
```bash
|
||||
# Full production build (tailwind → templ → go generate → go build)
|
||||
make build
|
||||
|
||||
@@ -23,11 +23,12 @@ func setupBun(cfg *config.Config) (conn *bun.DB, close func() error) {
|
||||
sqldb.SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
conn = bun.NewDB(sqldb, pgdialect.New())
|
||||
registerDBModels(conn)
|
||||
close = sqldb.Close
|
||||
return conn, close
|
||||
}
|
||||
|
||||
func registerDBModels(conn *bun.DB) {
|
||||
func registerDBModels(conn *bun.DB) []any {
|
||||
models := []any{
|
||||
(*db.RolePermission)(nil),
|
||||
(*db.UserRole)(nil),
|
||||
@@ -39,4 +40,5 @@ func registerDBModels(conn *bun.DB) {
|
||||
(*db.AuditLog)(nil),
|
||||
}
|
||||
conn.RegisterModel(models...)
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
|
||||
"git.haelnorr.com/h/oslstats/internal/backup"
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -349,10 +348,7 @@ func resetDatabase(ctx context.Context, cfg *config.Config) error {
|
||||
conn, close := setupBun(cfg)
|
||||
defer func() { _ = close() }()
|
||||
|
||||
models := []any{
|
||||
(*db.User)(nil),
|
||||
(*db.DiscordToken)(nil),
|
||||
}
|
||||
models := registerDBModels(conn)
|
||||
|
||||
for _, model := range models {
|
||||
if err := conn.ResetModel(ctx, model); err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -141,78 +142,7 @@ func init() {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seed system roles
|
||||
now := time.Now().Unix()
|
||||
|
||||
adminRole := &db.Role{
|
||||
Name: "admin",
|
||||
DisplayName: "Administrator",
|
||||
Description: "Full system access with all permissions",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(adminRole).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userRole := &db.Role{
|
||||
Name: "user",
|
||||
DisplayName: "User",
|
||||
Description: "Standard user with basic permissions",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(userRole).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seed system permissions
|
||||
permissionsData := []*db.Permission{
|
||||
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(&permissionsData).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Grant wildcard permission to admin role using Bun
|
||||
// First, get the IDs
|
||||
var wildcardPerm db.Permission
|
||||
err = dbConn.NewSelect().
|
||||
Model(&wildcardPerm).
|
||||
Where("name = ?", "*").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert role_permission mapping
|
||||
adminRolePerms := &db.RolePermission{
|
||||
RoleID: adminRole.ID,
|
||||
PermissionID: wildcardPerm.ID,
|
||||
}
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(adminRolePerms).
|
||||
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
err = seedSystemRBAC(ctx, dbConn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -242,3 +172,82 @@ func init() {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func seedSystemRBAC(ctx context.Context, dbConn *bun.DB) error {
|
||||
// Seed system roles
|
||||
now := time.Now().Unix()
|
||||
|
||||
adminRole := &db.Role{
|
||||
Name: "admin",
|
||||
DisplayName: "Administrator",
|
||||
Description: "Full system access with all permissions",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err := dbConn.NewInsert().
|
||||
Model(adminRole).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "dbConn.NewInsert")
|
||||
}
|
||||
|
||||
userRole := &db.Role{
|
||||
Name: "user",
|
||||
DisplayName: "User",
|
||||
Description: "Standard user with basic permissions",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(userRole).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "dbConn.NewInsert")
|
||||
}
|
||||
|
||||
// Seed system permissions
|
||||
permissionsData := []*db.Permission{
|
||||
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(&permissionsData).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "dbConn.NewInsert")
|
||||
}
|
||||
|
||||
// Grant wildcard permission to admin role using Bun
|
||||
// First, get the IDs
|
||||
var wildcardPerm db.Permission
|
||||
err = dbConn.NewSelect().
|
||||
Model(&wildcardPerm).
|
||||
Where("name = ?", "*").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert role_permission mapping
|
||||
adminRolePerms := &db.RolePermission{
|
||||
RoleID: adminRole.ID,
|
||||
PermissionID: wildcardPerm.ID,
|
||||
}
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(adminRolePerms).
|
||||
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "dbConn.NewInsert")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
|
||||
logger.Debug().Msg("Config loaded and logger started")
|
||||
logger.Debug().Msg("Connecting to database")
|
||||
bun, closedb := setupBun(cfg)
|
||||
registerDBModels(bun)
|
||||
// registerDBModels(bun)
|
||||
|
||||
// Setup embedded files
|
||||
logger.Debug().Msg("Getting embedded files")
|
||||
|
||||
2
go.sum
2
go.sum
@@ -8,8 +8,6 @@ git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4V
|
||||
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
||||
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
|
||||
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.6.0 h1:qaRsgfcD7M6xCasfXwP6Ww9RM4TwDqYMFK2YtO6nt6c=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.6.0/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.6.1 h1:3BiM6hwuYDjgfu02hshvUtr592DnWi9Epj//3N13ti0=
|
||||
git.haelnorr.com/h/golib/hwsauth v0.6.1/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
|
||||
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
||||
|
||||
@@ -156,3 +156,32 @@ func (l *Logger) CleanupOldLogs(ctx context.Context, daysToKeep int) (int, error
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Callback returns a db.AuditCallback that logs to this Logger
|
||||
// This is used with the generic database helpers (Insert, Update, Delete)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// audit := auditlog.NewLogger(conn)
|
||||
// err := db.Insert(tx, season).
|
||||
// WithAudit(r, audit.Callback()).
|
||||
// Exec(ctx)
|
||||
func (l *Logger) Callback() db.AuditCallback {
|
||||
return func(ctx context.Context, tx bun.Tx, info *db.AuditInfo, r *http.Request) error {
|
||||
user := db.CurrentUser(ctx)
|
||||
if user == nil {
|
||||
return errors.New("no user in context for audit logging")
|
||||
}
|
||||
|
||||
return l.LogSuccess(
|
||||
ctx,
|
||||
tx,
|
||||
user,
|
||||
info.Action,
|
||||
info.ResourceType,
|
||||
info.ResourceID,
|
||||
info.Details,
|
||||
r,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
146
internal/db/audit.go
Normal file
146
internal/db/audit.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// AuditInfo contains metadata for audit logging
|
||||
type AuditInfo struct {
|
||||
Action string // e.g., "seasons.create", "users.update"
|
||||
ResourceType string // e.g., "season", "user"
|
||||
ResourceID any // Primary key value (int, string, etc.)
|
||||
Details map[string]any // Changed fields or additional metadata
|
||||
}
|
||||
|
||||
// AuditCallback is called after successful database operations to log changes
|
||||
type AuditCallback func(ctx context.Context, tx bun.Tx, info *AuditInfo, r *http.Request) error
|
||||
|
||||
// extractTableName gets the bun table name from a model type using reflection
|
||||
// Example: Season with `bun:"table:seasons,alias:s"` returns "seasons"
|
||||
func extractTableName[T any]() string {
|
||||
var model T
|
||||
t := reflect.TypeOf(model)
|
||||
|
||||
// Handle pointer types
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
// Look for bun.BaseModel field with table tag
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.Type.Name() == "BaseModel" {
|
||||
bunTag := field.Tag.Get("bun")
|
||||
if bunTag != "" {
|
||||
// Parse tag: "table:seasons,alias:s" -> "seasons"
|
||||
parts := strings.Split(bunTag, ",")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "table:") {
|
||||
return strings.TrimPrefix(part, "table:")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use struct name in lowercase + "s"
|
||||
return strings.ToLower(t.Name()) + "s"
|
||||
}
|
||||
|
||||
// extractResourceType converts a table name to singular resource type
|
||||
// Example: "seasons" -> "season", "users" -> "user"
|
||||
func extractResourceType(tableName string) string {
|
||||
// Simple singularization: remove trailing 's'
|
||||
if strings.HasSuffix(tableName, "s") && len(tableName) > 1 {
|
||||
return tableName[:len(tableName)-1]
|
||||
}
|
||||
return tableName
|
||||
}
|
||||
|
||||
// buildAction creates a permission-style action string
|
||||
// Example: ("season", "create") -> "seasons.create"
|
||||
func buildAction(resourceType, operation string) string {
|
||||
// Pluralize resource type (simple: add 's')
|
||||
plural := resourceType
|
||||
if !strings.HasSuffix(plural, "s") {
|
||||
plural = plural + "s"
|
||||
}
|
||||
return plural + "." + operation
|
||||
}
|
||||
|
||||
// extractPrimaryKey uses reflection to find and return the primary key value from a model
|
||||
// Returns nil if no primary key is found
|
||||
func extractPrimaryKey[T any](model *T) any {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(model)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
bunTag := field.Tag.Get("bun")
|
||||
if bunTag != "" && strings.Contains(bunTag, "pk") {
|
||||
// Found primary key field
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.IsValid() && fieldValue.CanInterface() {
|
||||
return fieldValue.Interface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractChangedFields builds a map of field names to their new values
|
||||
// Only includes fields specified in the columns list
|
||||
func extractChangedFields[T any](model *T, columns []string) map[string]any {
|
||||
if model == nil || len(columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
v := reflect.ValueOf(model)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
t := v.Type()
|
||||
|
||||
// Build map of bun column names to field names
|
||||
columnToField := make(map[string]int)
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
bunTag := field.Tag.Get("bun")
|
||||
if bunTag != "" {
|
||||
// Parse bun tag to get column name (first part before comma)
|
||||
parts := strings.Split(bunTag, ",")
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
columnToField[parts[0]] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract values for requested columns
|
||||
for _, col := range columns {
|
||||
if fieldIdx, ok := columnToField[col]; ok {
|
||||
fieldValue := v.Field(fieldIdx)
|
||||
if fieldValue.IsValid() && fieldValue.CanInterface() {
|
||||
result[col] = fieldValue.Interface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Note: We don't need getTxFromQuery since we store the tx directly in our helper structs
|
||||
@@ -3,13 +3,18 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type deleter[T any] struct {
|
||||
tx bun.Tx
|
||||
q *bun.DeleteQuery
|
||||
resourceID any // Store ID before deletion for audit
|
||||
auditCallback AuditCallback
|
||||
auditRequest *http.Request
|
||||
}
|
||||
|
||||
type systemType interface {
|
||||
@@ -18,13 +23,27 @@ type systemType interface {
|
||||
|
||||
func DeleteItem[T any](tx bun.Tx) *deleter[T] {
|
||||
return &deleter[T]{
|
||||
tx.NewDelete().
|
||||
tx: tx,
|
||||
q: tx.NewDelete().
|
||||
Model((*T)(nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deleter[T]) Where(query string, args ...any) *deleter[T] {
|
||||
d.q = d.q.Where(query, args...)
|
||||
// Try to capture resource ID from WHERE clause if it's a simple "id = ?" pattern
|
||||
if query == "id = ?" && len(args) > 0 {
|
||||
d.resourceID = args[0]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// WithAudit enables audit logging for this delete operation
|
||||
// The callback will be invoked after successful deletion with auto-generated audit info
|
||||
// If the callback returns an error, the transaction will be rolled back
|
||||
func (d *deleter[T]) WithAudit(r *http.Request, callback AuditCallback) *deleter[T] {
|
||||
d.auditRequest = r
|
||||
d.auditCallback = callback
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -34,10 +53,31 @@ func (d *deleter[T]) Delete(ctx context.Context) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.Wrap(err, "bun.DeleteQuery.Exec")
|
||||
}
|
||||
|
||||
// Handle audit logging if enabled
|
||||
if d.auditCallback != nil && d.auditRequest != nil {
|
||||
tableName := extractTableName[T]()
|
||||
resourceType := extractResourceType(tableName)
|
||||
action := buildAction(resourceType, "delete")
|
||||
|
||||
info := &AuditInfo{
|
||||
Action: action,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: d.resourceID,
|
||||
Details: nil, // Delete doesn't need details
|
||||
}
|
||||
|
||||
// Call audit callback - if it fails, return error to trigger rollback
|
||||
if err := d.auditCallback(ctx, d.tx, info, d.auditRequest); err != nil {
|
||||
return errors.Wrap(err, "audit.callback")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteByID[T any](tx bun.Tx, id int) *deleter[T] {
|
||||
return DeleteItem[T](tx).Where("id = ?", id)
|
||||
}
|
||||
|
||||
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"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
Name string `bun:"name,unique"`
|
||||
ShortName string `bun:"short_name,unique"`
|
||||
Name string `bun:"name,unique,notnull"`
|
||||
ShortName string `bun:"short_name,unique,notnull"`
|
||||
StartDate time.Time `bun:"start_date,notnull"`
|
||||
EndDate bun.NullTime `bun:"end_date"`
|
||||
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
|
||||
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
|
||||
}
|
||||
|
||||
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
}
|
||||
if shortname == "" {
|
||||
return nil, errors.New("shortname cannot be empty")
|
||||
}
|
||||
// NewSeason returns a new season. It does not add it to the database
|
||||
func NewSeason(name, shortname string, start time.Time) *Season {
|
||||
season := &Season{
|
||||
Name: name,
|
||||
ShortName: strings.ToUpper(shortname),
|
||||
StartDate: start.Truncate(time.Hour * 24),
|
||||
}
|
||||
_, err := tx.NewInsert().
|
||||
Model(season).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewInsert")
|
||||
}
|
||||
return season, nil
|
||||
return season
|
||||
}
|
||||
|
||||
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[Season], error) {
|
||||
@@ -60,31 +48,16 @@ func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error
|
||||
return GetByField[Season](tx, "short_name", shortname).GetFirst(ctx)
|
||||
}
|
||||
|
||||
func UpdateSeason(ctx context.Context, tx bun.Tx, season *Season) error {
|
||||
if season == nil {
|
||||
return errors.New("season cannot be nil")
|
||||
// Update updates the season struct. It does not insert to the database
|
||||
func (s *Season) Update(start, end, finalsStart, finalsEnd time.Time) {
|
||||
s.StartDate = start.Truncate(time.Hour * 24)
|
||||
if !end.IsZero() {
|
||||
s.EndDate.Time = end.Truncate(time.Hour * 24)
|
||||
}
|
||||
if season.ID == 0 {
|
||||
return errors.New("season ID cannot be 0")
|
||||
if !finalsStart.IsZero() {
|
||||
s.FinalsStartDate.Time = finalsStart.Truncate(time.Hour * 24)
|
||||
}
|
||||
// Truncate dates to day precision
|
||||
season.StartDate = season.StartDate.Truncate(time.Hour * 24)
|
||||
if !season.EndDate.IsZero() {
|
||||
season.EndDate.Time = season.EndDate.Time.Truncate(time.Hour * 24)
|
||||
if !finalsEnd.IsZero() {
|
||||
s.FinalsEndDate.Time = finalsEnd.Truncate(time.Hour * 24)
|
||||
}
|
||||
if !season.FinalsStartDate.IsZero() {
|
||||
season.FinalsStartDate.Time = season.FinalsStartDate.Time.Truncate(time.Hour * 24)
|
||||
}
|
||||
if !season.FinalsEndDate.IsZero() {
|
||||
season.FinalsEndDate.Time = season.FinalsEndDate.Time.Truncate(time.Hour * 24)
|
||||
}
|
||||
_, err := tx.NewUpdate().
|
||||
Model(season).
|
||||
Column("start_date", "end_date", "finals_start_date", "finals_end_date").
|
||||
Where("id = ?", season.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewUpdate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
115
internal/db/update.go
Normal file
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-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--color-rosewater: var(--rosewater);
|
||||
--color-flamingo: var(--flamingo);
|
||||
--color-pink: var(--pink);
|
||||
--color-mauve: var(--mauve);
|
||||
--color-red: var(--red);
|
||||
--color-dark-red: var(--dark-red);
|
||||
--color-maroon: var(--maroon);
|
||||
--color-peach: var(--peach);
|
||||
--color-yellow: var(--yellow);
|
||||
--color-dark-yellow: var(--dark-yellow);
|
||||
--color-green: var(--green);
|
||||
--color-dark-green: var(--dark-green);
|
||||
--color-teal: var(--teal);
|
||||
--color-sky: var(--sky);
|
||||
--color-sapphire: var(--sapphire);
|
||||
--color-blue: var(--blue);
|
||||
--color-dark-blue: var(--dark-blue);
|
||||
--color-lavender: var(--lavender);
|
||||
--color-text: var(--text);
|
||||
--color-subtext1: var(--subtext1);
|
||||
--color-subtext0: var(--subtext0);
|
||||
--color-overlay2: var(--overlay2);
|
||||
--color-overlay1: var(--overlay1);
|
||||
--color-overlay0: var(--overlay0);
|
||||
--color-surface2: var(--surface2);
|
||||
--color-surface1: var(--surface1);
|
||||
--color-surface0: var(--surface0);
|
||||
--color-base: var(--base);
|
||||
--color-mantle: var(--mantle);
|
||||
--color-crust: var(--crust);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
@@ -273,9 +243,6 @@
|
||||
.top-0 {
|
||||
top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.top-1 {
|
||||
top: calc(var(--spacing) * 1);
|
||||
}
|
||||
.top-1\/2 {
|
||||
top: calc(1/2 * 100%);
|
||||
}
|
||||
@@ -309,24 +276,6 @@
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
max-width: 80rem;
|
||||
}
|
||||
@media (width >= 96rem) {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -496,25 +445,9 @@
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.-translate-y-1 {
|
||||
--tw-translate-y: calc(var(--spacing) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.-translate-y-1\/2 {
|
||||
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
@@ -623,11 +556,6 @@
|
||||
border-color: var(--surface2);
|
||||
}
|
||||
}
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -734,9 +662,6 @@
|
||||
.bg-mauve {
|
||||
background-color: var(--mauve);
|
||||
}
|
||||
.bg-red {
|
||||
background-color: var(--red);
|
||||
}
|
||||
.bg-red\/10 {
|
||||
background-color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -933,9 +858,6 @@
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.opacity-50 {
|
||||
opacity: 50%;
|
||||
}
|
||||
@@ -951,10 +873,6 @@
|
||||
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
@@ -1539,11 +1457,6 @@
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -1628,7 +1541,6 @@
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-outline-style: solid;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
--tw-contrast: initial;
|
||||
|
||||
@@ -43,10 +43,7 @@ func NotificationWS(
|
||||
if err != nil {
|
||||
logError(s, "Notification error", errors.Wrap(err, "notifyLoop"))
|
||||
}
|
||||
err = ws.CloseNow()
|
||||
if err != nil {
|
||||
logError(s, "Error closing websocket", errors.Wrap(err, "ws.CloseNow"))
|
||||
}
|
||||
_ = ws.CloseNow()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,12 @@ func Register(
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.UpdateDiscordToken")
|
||||
}
|
||||
if shouldGrantAdmin(user, cfg.RBAC) {
|
||||
err := ensureUserHasAdminRole(ctx, tx, user)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "ensureUserHasAdminRole")
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||
@@ -78,36 +77,14 @@ func SeasonEditSubmit(
|
||||
return false, errors.Wrap(err, "db.GetSeason")
|
||||
}
|
||||
if season == nil {
|
||||
return true, nil
|
||||
return false, errors.New("season does not exist")
|
||||
}
|
||||
|
||||
// Update only the date fields
|
||||
season.StartDate = startDate
|
||||
if !endDate.IsZero() {
|
||||
season.EndDate = bun.NullTime{Time: endDate}
|
||||
} else {
|
||||
season.EndDate = bun.NullTime{}
|
||||
}
|
||||
if !finalsStartDate.IsZero() {
|
||||
season.FinalsStartDate = bun.NullTime{Time: finalsStartDate}
|
||||
} else {
|
||||
season.FinalsStartDate = bun.NullTime{}
|
||||
}
|
||||
if !finalsEndDate.IsZero() {
|
||||
season.FinalsEndDate = bun.NullTime{Time: finalsEndDate}
|
||||
} else {
|
||||
season.FinalsEndDate = bun.NullTime{}
|
||||
}
|
||||
|
||||
err = db.UpdateSeason(ctx, tx, season)
|
||||
season.Update(startDate, endDate, finalsStartDate, finalsEndDate)
|
||||
err = db.Update(tx, season).WherePK().
|
||||
Column("start_date", "end_date", "finals_start_date", "finals_end_date").
|
||||
WithAudit(r, audit.Callback()).Exec(ctx)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.UpdateSeason")
|
||||
}
|
||||
user := db.CurrentUser(ctx)
|
||||
err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(),
|
||||
"season", season.ID, nil, r)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "audit.LogSuccess")
|
||||
return false, errors.Wrap(err, "db.Update")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
@@ -119,8 +96,8 @@ func SeasonEditSubmit(
|
||||
return
|
||||
}
|
||||
|
||||
notify.Success(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
|
||||
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
notify.SuccessWithDelay(s, w, r, "Season Updated", fmt.Sprintf("Successfully updated season: %s", season.Name), nil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||
seasonsview "git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||
"git.haelnorr.com/h/timefmt"
|
||||
@@ -70,15 +69,10 @@ func NewSeasonSubmit(
|
||||
if !nameUnique || !shortNameUnique {
|
||||
return true, nil
|
||||
}
|
||||
season, err = db.NewSeason(ctx, tx, name, shortName, startDate)
|
||||
season = db.NewSeason(name, shortName, startDate)
|
||||
err = db.Insert(tx, season).WithAudit(r, audit.Callback()).Exec(ctx)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.NewSeason")
|
||||
}
|
||||
user := db.CurrentUser(ctx)
|
||||
err = audit.LogSuccess(ctx, tx, user, permissions.SeasonsCreate.String(),
|
||||
"season", season.ID, nil, r)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "audit.LogSuccess")
|
||||
return false, errors.Wrap(err, "db.Insert")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
@@ -94,8 +88,8 @@ func NewSeasonSubmit(
|
||||
notify.Warn(s, w, r, "Duplicate Short Name", "This short name is already taken.", nil)
|
||||
return
|
||||
}
|
||||
notify.Success(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
|
||||
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s", season.ShortName))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
notify.SuccessWithDelay(s, w, r, "Season Created", fmt.Sprintf("Successfully created season: %s", name), nil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package notify
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/notify"
|
||||
@@ -61,3 +62,11 @@ func Info(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg stri
|
||||
func Success(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
|
||||
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
|
||||
}
|
||||
|
||||
// SuccessWithDelay notifies with success level and a short delay to account for redirects
|
||||
func SuccessWithDelay(s *hws.Server, w http.ResponseWriter, r *http.Request, title, msg string, action any) {
|
||||
go func() {
|
||||
<-time.Tick(1 * time.Second)
|
||||
notifyClient(s, w, r, notify.LevelSuccess, title, msg, "", action)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -144,10 +144,8 @@ func formatDateLong(t time.Time) string {
|
||||
}
|
||||
|
||||
func formatDuration(start, end time.Time) string {
|
||||
days := int(end.Sub(start).Hours() / 24)
|
||||
if days == 0 {
|
||||
return "1 day"
|
||||
} else if days == 1 {
|
||||
days := int(end.Sub(start).Hours()/24) + 1
|
||||
if days == 1 {
|
||||
return "1 day"
|
||||
} else if days < 7 {
|
||||
return strconv.Itoa(days) + " days"
|
||||
|
||||
Reference in New Issue
Block a user