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 settings *database.SettingsRegistry } func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler { return &SystemSettingsHandler{ db: db, } } // 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"` } type ScanSettingsResponse struct { ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds"` AutoScanEnabled bool `json:"auto_scan_enabled"` Message string `json:"message,omitempty"` } type UpdateTimezoneSettingsRequest struct { DefaultTimezone string `json:"default_timezone" validate:"required"` } func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error { var req UpdateTimezoneSettingsRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if _, err := time.LoadLocation(req.DefaultTimezone); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"}) } err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{ SettingKey: "default_timezone", SettingValue: req.DefaultTimezone, }) if err != nil { return err } h.reload(c) return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"}) } func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { var req UpdateScanSettingsRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } scanFrequencyValue := strconv.FormatInt(int64(req.ScanPollIntervalSeconds), 10) autoScanValue := strconv.FormatBool(req.AutoScanEnabled) err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{ SettingKey: "scan_poll_interval_seconds", SettingValue: scanFrequencyValue, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } err = h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{ SettingKey: "auto_scan_enabled", SettingValue: autoScanValue, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"}) } 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, Message: "scan settings updated successfully", }) } 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) { return c.JSON(http.StatusOK, ScanSettingsResponse{ ScanPollIntervalSeconds: 1800, AutoScanEnabled: true, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } autoScanSetting, err := h.db.GetSystemSetting(c.Request().Context(), "auto_scan_enabled") if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusOK, ScanSettingsResponse{ ScanPollIntervalSeconds: 1800, AutoScanEnabled: true, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } scanFrequency, err := strconv.ParseInt(scanFrequencySetting, 10, 32) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "invalid scan frequency setting"}) } autoScanEnabled, err := strconv.ParseBool(autoScanSetting) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "invalid auto scan setting"}) } return c.JSON(http.StatusOK, ScanSettingsResponse{ ScanPollIntervalSeconds: int32(scanFrequency), AutoScanEnabled: autoScanEnabled, }) }