From 885f6d8187efa39cd46253b53c88485f20ff5133 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:02:22 -0400 Subject: [PATCH] feat(api): unified tunable settings endpoints with typed validation Add a single pair of admin-only endpoints that supersede the scattered scan-settings JSON routes as the canonical way to read and write tunable system settings. Existing legacy routes are kept working for backward compatibility and now refresh the registry cache on write. system_settings.go: - GET /api/system/settings returns every known setting with full metadata (value, type, min, max, requires_restart, category, group, description, is_default) via SettingsRegistry.All(). - PUT /api/system/settings accepts {key, value}; ApplySetting() looks up the compiled Default for the key, runs type-aware validation (int range, bool parse, non-empty string, timezone via time.LoadLocation), upserts via UpsertSystemSetting, reloads the registry, and reports whether a restart is needed for the change to take full effect. Shared by the JSON endpoint and the HTMX endpoint. - Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings now reload the registry after writing and prefer the registry when reading, so the cache stays consistent regardless of entry point. sidecar.go: - SidecarHandler gains an optional registry; the timezone branch of UpdateSystemConfiguration (PUT /api/system/config) calls settings.Reload() after the write so the new value is visible immediately. base_url handling is unchanged. system.go: - Register GET/PUT /api/system/settings under the existing admin /api/system group. --- internal/handlers/sidecar.go | 12 +- internal/handlers/system_settings.go | 166 ++++++++++++++++++++++++++- internal/router/system.go | 6 + 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/internal/handlers/sidecar.go b/internal/handlers/sidecar.go index bf36b06..3bd0f77 100644 --- a/internal/handlers/sidecar.go +++ b/internal/handlers/sidecar.go @@ -15,14 +15,19 @@ import ( ) type SidecarHandler struct { - db *database.Queries - cfg *config.Config + db *database.Queries + cfg *config.Config + settings *database.SettingsRegistry } func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler { return &SidecarHandler{db: db, cfg: cfg} } +// SetSettings wires the tunable settings registry so the timezone write path +// keeps the cache consistent. +func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } + type SidecarConfig struct { Version string `json:"version"` Bookhoard SidecarBookhoardConfig `json:"bookhoard"` @@ -400,6 +405,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error { "error": "failed to update default timezone", }) } + if h.settings != nil { + h.settings.Reload(ctx) + } continue } _, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{ diff --git a/internal/handlers/system_settings.go b/internal/handlers/system_settings.go index 7e3c1eb..762a84d 100644 --- a/internal/handlers/system_settings.go +++ b/internal/handlers/system_settings.go @@ -2,17 +2,21 @@ package handlers import ( "bookhoard/internal/database" + "context" "errors" + "fmt" "net/http" "strconv" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) type SystemSettingsHandler struct { - db *database.Queries + db *database.Queries + settings *database.SettingsRegistry } func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler { @@ -21,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler { } } +// SetSettings wires the tunable settings registry. Required for the unified +// /api/system/settings endpoints and for cache invalidation after writes. +func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) { + h.settings = s +} + +// reload refreshes the in-memory cache after a write. +func (h *SystemSettingsHandler) reload(c *echo.Context) { + if h.settings != nil { + h.settings.Reload(c.Request().Context()) + } +} + +// ---- Unified /api/system/settings endpoints ---- + +// GetSettings handles GET /api/system/settings. +func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error { + if h.settings == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"}) + } + return c.JSON(http.StatusOK, h.settings.All()) +} + +// UpdateSettingRequest is the body for PUT /api/system/settings. +type UpdateSettingRequest struct { + Key string `json:"key" form:"key"` + Value string `json:"value" form:"value"` +} + +// UpdateSettingResponse mirrors a settings entry plus a reload hint. +type UpdateSettingResponse struct { + database.SettingEntry + ReloadRequired bool `json:"reload_required"` + Message string `json:"message,omitempty"` +} + +// UpdateSetting handles PUT /api/system/settings. +func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error { + if h.settings == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"}) + } + var req UpdateSettingRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, resp) +} + +// ApplySetting validates, persists, and reloads a single setting. Shared by the +// JSON API and the HTMX admin endpoint. +func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) { + if h.settings == nil { + return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized") + } + if key == "" { + return UpdateSettingResponse{}, fmt.Errorf("key is required") + } + def, ok := database.LookupDefault(key) + if !ok { + return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key) + } + if err := validateSettingValue(def, value); err != nil { + return UpdateSettingResponse{}, err + } + + desc := def.Description + rType := pgtype.Text{} + if def.Type != "" { + rType = pgtype.Text{String: def.Type, Valid: true} + } + var minP, maxP pgtype.Text + if def.Min != "" { + minP = pgtype.Text{String: def.Min, Valid: true} + } + if def.Max != "" { + maxP = pgtype.Text{String: def.Max, Valid: true} + } + if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{ + SettingKey: key, + SettingValue: value, + Description: pgtype.Text{String: desc, Valid: desc != ""}, + SettingType: rType, + MinValue: minP, + MaxValue: maxP, + RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true}, + Category: pgtype.Text{String: def.Category, Valid: def.Category != ""}, + }); err != nil { + return UpdateSettingResponse{}, err + } + + h.settings.Reload(ctx) + + resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart} + for _, e := range h.settings.All() { + if e.Key == key { + resp.SettingEntry = e + break + } + } + if def.RequiresRestart { + resp.Message = "Saved. Restart the server for this change to take full effect." + } else { + resp.Message = "Saved." + } + return resp, nil +} + +// validateSettingValue checks a candidate value against the setting's type and bounds. +func validateSettingValue(def database.SettingDefault, value string) error { + switch def.Type { + case database.SettingTypeInt: + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("value must be an integer") + } + if def.Min != "" { + if mn, err := strconv.Atoi(def.Min); err == nil && n < mn { + return fmt.Errorf("value must be >= %s", def.Min) + } + } + if def.Max != "" { + if mx, err := strconv.Atoi(def.Max); err == nil && n > mx { + return fmt.Errorf("value must be <= %s", def.Max) + } + } + case database.SettingTypeBool: + if _, err := strconv.ParseBool(value); err != nil { + return fmt.Errorf("value must be true or false") + } + case database.SettingTypeString: + if value == "" { + return fmt.Errorf("value must not be empty") + } + if def.Key == "default_timezone" { + if _, err := time.LoadLocation(value); err != nil { + return fmt.Errorf("invalid timezone: %v", err) + } + } + } + return nil +} + +// ---- Legacy scan-settings endpoints (retained for backward compatibility) ---- + type UpdateScanSettingsRequest struct { ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"` AutoScanEnabled bool `json:"auto_scan_enabled"` @@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error { if err != nil { return err } + h.reload(c) return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"}) } @@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + h.reload(c) + return c.JSON(http.StatusOK, ScanSettingsResponse{ ScanPollIntervalSeconds: req.ScanPollIntervalSeconds, AutoScanEnabled: req.AutoScanEnabled, @@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { } func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error { + // Prefer the registry (single source of truth after Load). + if h.settings != nil { + interval := int32(h.settings.ScanPollInterval().Seconds()) + return c.JSON(http.StatusOK, ScanSettingsResponse{ + ScanPollIntervalSeconds: interval, + AutoScanEnabled: h.settings.AutoScanEnabled(), + }) + } + scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds") if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/router/system.go b/internal/router/system.go index 4c76f8a..aa2c8f7 100644 --- a/internal/router/system.go +++ b/internal/router/system.go @@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) { // System configuration routes (admin-only) system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration) system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration) + + // Unified tunable settings (admin-only). These back the admin UI's + // editable System Settings sections and supersede the legacy + // /api/libraries/scan-settings JSON routes. + system.GET("/settings", cfg.SystemSettingsHandler.GetSettings) + system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting) }