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) }