diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b5afdb1..8302d76 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -51,6 +51,7 @@ type Server struct { jobDefsDir string modules *modules.Registry cfg *config.Config + settings *db.SettingsRepository } // NewServer creates a new API server. @@ -77,6 +78,7 @@ func NewServer( itemFiles := db.NewItemFileRepository(database) dag := db.NewDAGRepository(database) jobs := db.NewJobRepository(database) + settings := db.NewSettingsRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -103,6 +105,7 @@ func NewServer( jobDefsDir: jobDefsDir, modules: registry, cfg: cfg, + settings: settings, } } diff --git a/internal/api/routes.go b/internal/api/routes.go index 9829c6c..d099b0b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -262,6 +262,15 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/generate-part-number", server.HandleGeneratePartNumber) }) + + // Admin settings (admin only) + r.Route("/admin/settings", func(r chi.Router) { + r.Use(server.RequireRole(auth.RoleAdmin)) + r.Get("/", server.HandleGetAllSettings) + r.Get("/{module}", server.HandleGetModuleSettings) + r.Put("/{module}", server.HandleUpdateModuleSettings) + r.Post("/{module}/test", server.HandleTestModuleConnectivity) + }) }) // Runner-facing API (runner token auth, not user auth) diff --git a/internal/api/settings_handlers.go b/internal/api/settings_handlers.go new file mode 100644 index 0000000..f695f41 --- /dev/null +++ b/internal/api/settings_handlers.go @@ -0,0 +1,316 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" +) + +// HandleGetAllSettings returns the full config grouped by module with secrets redacted. +func (s *Server) HandleGetAllSettings(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "core": s.buildCoreSettings(), + "schemas": s.buildSchemasSettings(), + "storage": s.buildStorageSettings(r.Context()), + "database": s.buildDatabaseSettings(r.Context()), + "auth": s.buildAuthSettings(), + "projects": map[string]any{"enabled": s.modules.IsEnabled("projects")}, + "audit": map[string]any{"enabled": s.modules.IsEnabled("audit")}, + "odoo": s.buildOdooSettings(), + "freecad": s.buildFreecadSettings(), + "jobs": s.buildJobsSettings(), + "dag": map[string]any{"enabled": s.modules.IsEnabled("dag")}, + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetModuleSettings returns settings for a single module. +func (s *Server) HandleGetModuleSettings(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + var settings any + switch module { + case "core": + settings = s.buildCoreSettings() + case "schemas": + settings = s.buildSchemasSettings() + case "storage": + settings = s.buildStorageSettings(r.Context()) + case "database": + settings = s.buildDatabaseSettings(r.Context()) + case "auth": + settings = s.buildAuthSettings() + case "projects": + settings = map[string]any{"enabled": s.modules.IsEnabled("projects")} + case "audit": + settings = map[string]any{"enabled": s.modules.IsEnabled("audit")} + case "odoo": + settings = s.buildOdooSettings() + case "freecad": + settings = s.buildFreecadSettings() + case "jobs": + settings = s.buildJobsSettings() + case "dag": + settings = map[string]any{"enabled": s.modules.IsEnabled("dag")} + default: + writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module) + return + } + + writeJSON(w, http.StatusOK, settings) +} + +// HandleUpdateModuleSettings handles module toggle and config overrides. +func (s *Server) HandleUpdateModuleSettings(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + // Validate module exists + if s.modules.Get(module) == nil { + writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module) + return + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + user := auth.UserFromContext(r.Context()) + username := "system" + if user != nil { + username = user.Username + } + + var updated []string + restartRequired := false + + // Handle module toggle + if enabledVal, ok := body["enabled"]; ok { + enabled, ok := enabledVal.(bool) + if !ok { + writeError(w, http.StatusBadRequest, "invalid_value", "'enabled' must be a boolean") + return + } + + if err := s.modules.SetEnabled(module, enabled); err != nil { + writeError(w, http.StatusBadRequest, "toggle_failed", err.Error()) + return + } + + if err := s.settings.SetModuleState(r.Context(), module, enabled, username); err != nil { + s.logger.Error().Err(err).Str("module", module).Msg("failed to persist module state") + writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save module state") + return + } + + updated = append(updated, module+".enabled") + } + + // Handle config overrides (future use — persisted but not hot-reloaded) + for key, value := range body { + if key == "enabled" { + continue + } + + fullKey := module + "." + key + if err := s.settings.SetOverride(r.Context(), fullKey, value, username); err != nil { + s.logger.Error().Err(err).Str("key", fullKey).Msg("failed to persist setting override") + writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save setting: "+key) + return + } + updated = append(updated, fullKey) + + // These namespaces require a restart to take effect + if strings.HasPrefix(fullKey, "database.") || + strings.HasPrefix(fullKey, "storage.") || + strings.HasPrefix(fullKey, "server.") || + strings.HasPrefix(fullKey, "schemas.") { + restartRequired = true + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "updated": updated, + "restart_required": restartRequired, + }) + + // Publish SSE event + s.broker.Publish("settings.changed", mustMarshal(map[string]any{ + "module": module, + "changed_keys": updated, + "updated_by": username, + })) +} + +// HandleTestModuleConnectivity tests external connectivity for a module. +func (s *Server) HandleTestModuleConnectivity(w http.ResponseWriter, r *http.Request) { + module := chi.URLParam(r, "module") + + start := time.Now() + var success bool + var message string + + switch module { + case "database": + if err := s.db.Pool().Ping(r.Context()); err != nil { + success = false + message = "Database ping failed: " + err.Error() + } else { + success = true + message = "Database connection OK" + } + case "storage": + if s.storage == nil { + success = false + message = "Storage not configured" + } else if err := s.storage.Ping(r.Context()); err != nil { + success = false + message = "Storage ping failed: " + err.Error() + } else { + success = true + message = "Storage connection OK" + } + case "auth", "odoo": + success = false + message = "Connectivity test not implemented for " + module + default: + writeError(w, http.StatusBadRequest, "not_testable", "No connectivity test available for module: "+module) + return + } + + latency := time.Since(start).Milliseconds() + + writeJSON(w, http.StatusOK, map[string]any{ + "success": success, + "message": message, + "latency_ms": latency, + }) +} + +// --- build helpers (read config, redact secrets) --- + +func redact(s string) string { + if s == "" { + return "" + } + return "****" +} + +func (s *Server) buildCoreSettings() map[string]any { + return map[string]any{ + "enabled": true, + "host": s.cfg.Server.Host, + "port": s.cfg.Server.Port, + "base_url": s.cfg.Server.BaseURL, + "readonly": s.cfg.Server.ReadOnly, + } +} + +func (s *Server) buildSchemasSettings() map[string]any { + return map[string]any{ + "enabled": true, + "directory": s.cfg.Schemas.Directory, + "default": s.cfg.Schemas.Default, + "count": len(s.schemas), + } +} + +func (s *Server) buildStorageSettings(ctx context.Context) map[string]any { + result := map[string]any{ + "enabled": true, + "endpoint": s.cfg.Storage.Endpoint, + "bucket": s.cfg.Storage.Bucket, + "use_ssl": s.cfg.Storage.UseSSL, + "region": s.cfg.Storage.Region, + } + if s.storage != nil { + if err := s.storage.Ping(ctx); err != nil { + result["status"] = "unavailable" + } else { + result["status"] = "ok" + } + } else { + result["status"] = "not_configured" + } + return result +} + +func (s *Server) buildDatabaseSettings(ctx context.Context) map[string]any { + result := map[string]any{ + "enabled": true, + "host": s.cfg.Database.Host, + "port": s.cfg.Database.Port, + "name": s.cfg.Database.Name, + "user": s.cfg.Database.User, + "password": redact(s.cfg.Database.Password), + "sslmode": s.cfg.Database.SSLMode, + "max_connections": s.cfg.Database.MaxConnections, + } + if err := s.db.Pool().Ping(ctx); err != nil { + result["status"] = "unavailable" + } else { + result["status"] = "ok" + } + return result +} + +func (s *Server) buildAuthSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("auth"), + "session_secret": redact(s.cfg.Auth.SessionSecret), + "local": map[string]any{ + "enabled": s.cfg.Auth.Local.Enabled, + "default_admin_username": s.cfg.Auth.Local.DefaultAdminUsername, + "default_admin_password": redact(s.cfg.Auth.Local.DefaultAdminPassword), + }, + "ldap": map[string]any{ + "enabled": s.cfg.Auth.LDAP.Enabled, + "url": s.cfg.Auth.LDAP.URL, + "base_dn": s.cfg.Auth.LDAP.BaseDN, + "bind_dn": s.cfg.Auth.LDAP.BindDN, + "bind_password": redact(s.cfg.Auth.LDAP.BindPassword), + }, + "oidc": map[string]any{ + "enabled": s.cfg.Auth.OIDC.Enabled, + "issuer_url": s.cfg.Auth.OIDC.IssuerURL, + "client_id": s.cfg.Auth.OIDC.ClientID, + "client_secret": redact(s.cfg.Auth.OIDC.ClientSecret), + "redirect_url": s.cfg.Auth.OIDC.RedirectURL, + }, + } +} + +func (s *Server) buildOdooSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("odoo"), + "url": s.cfg.Odoo.URL, + "database": s.cfg.Odoo.Database, + "username": s.cfg.Odoo.Username, + "api_key": redact(s.cfg.Odoo.APIKey), + } +} + +func (s *Server) buildFreecadSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("freecad"), + "uri_scheme": s.cfg.FreeCAD.URIScheme, + "executable": s.cfg.FreeCAD.Executable, + } +} + +func (s *Server) buildJobsSettings() map[string]any { + return map[string]any{ + "enabled": s.modules.IsEnabled("jobs"), + "directory": s.cfg.Jobs.Directory, + "runner_timeout": s.cfg.Jobs.RunnerTimeout, + "job_timeout_check": s.cfg.Jobs.JobTimeoutCheck, + "default_priority": s.cfg.Jobs.DefaultPriority, + "definitions_count": len(s.jobDefs), + } +} diff --git a/internal/api/settings_handlers_test.go b/internal/api/settings_handlers_test.go new file mode 100644 index 0000000..716afca --- /dev/null +++ b/internal/api/settings_handlers_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/config" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/modules" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/testutil" + "github.com/rs/zerolog" +) + +func newSettingsTestServer(t *testing.T) *Server { + t.Helper() + pool := testutil.MustConnectTestPool(t) + database := db.NewFromPool(pool) + broker := NewBroker(zerolog.Nop()) + state := NewServerState(zerolog.Nop(), nil, broker) + cfg := &config.Config{ + Server: config.ServerConfig{Host: "0.0.0.0", Port: 8080}, + Database: config.DatabaseConfig{ + Host: "localhost", Port: 5432, Name: "silo_test", + User: "silo", Password: "secret", SSLMode: "disable", + MaxConnections: 10, + }, + Storage: config.StorageConfig{ + Endpoint: "minio:9000", Bucket: "silo", Region: "us-east-1", + AccessKey: "minioadmin", SecretKey: "miniosecret", + }, + Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"}, + Auth: config.AuthConfig{ + SessionSecret: "supersecret", + Local: config.LocalAuth{Enabled: true, DefaultAdminUsername: "admin", DefaultAdminPassword: "changeme"}, + LDAP: config.LDAPAuth{Enabled: false, BindPassword: "ldapsecret"}, + OIDC: config.OIDCAuth{Enabled: false, ClientSecret: "oidcsecret"}, + }, + FreeCAD: config.FreeCADConfig{URIScheme: "silo"}, + Odoo: config.OdooConfig{URL: "https://odoo.example.com", APIKey: "odoo-api-key"}, + Jobs: config.JobsConfig{Directory: "/etc/silo/jobdefs", RunnerTimeout: 90, JobTimeoutCheck: 30, DefaultPriority: 100}, + } + return NewServer( + zerolog.Nop(), + database, + map[string]*schema.Schema{"test": {Name: "test"}}, + cfg.Schemas.Directory, + nil, // storage + nil, // authService + nil, // sessionManager + nil, // oidcBackend + nil, // authConfig + broker, + state, + nil, // jobDefs + "", // jobDefsDir + modules.NewRegistry(), // modules + cfg, + ) +} + +func newSettingsRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/admin/settings", func(r chi.Router) { + r.Get("/", s.HandleGetAllSettings) + r.Get("/{module}", s.HandleGetModuleSettings) + r.Put("/{module}", s.HandleUpdateModuleSettings) + r.Post("/{module}/test", s.HandleTestModuleConnectivity) + }) + return r +} + +func adminSettingsRequest(r *http.Request) *http.Request { + u := &auth.User{ + ID: "admin-id", + Username: "testadmin", + Role: auth.RoleAdmin, + } + return r.WithContext(auth.ContextWithUser(r.Context(), u)) +} + +func viewerSettingsRequest(r *http.Request) *http.Request { + u := &auth.User{ + ID: "viewer-id", + Username: "testviewer", + Role: auth.RoleViewer, + } + return r.WithContext(auth.ContextWithUser(r.Context(), u)) +} + +func TestGetAllSettings(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + // Verify all module keys present + expectedModules := []string{"core", "schemas", "storage", "database", "auth", "projects", "audit", "odoo", "freecad", "jobs", "dag"} + for _, mod := range expectedModules { + if _, ok := resp[mod]; !ok { + t.Errorf("missing module key: %s", mod) + } + } + + // Verify secrets are redacted + dbSettings, _ := resp["database"].(map[string]any) + if dbSettings["password"] != "****" { + t.Errorf("database password not redacted: got %v", dbSettings["password"]) + } + + authSettings, _ := resp["auth"].(map[string]any) + if authSettings["session_secret"] != "****" { + t.Errorf("session_secret not redacted: got %v", authSettings["session_secret"]) + } + + ldap, _ := authSettings["ldap"].(map[string]any) + if ldap["bind_password"] != "****" { + t.Errorf("ldap bind_password not redacted: got %v", ldap["bind_password"]) + } + + oidc, _ := authSettings["oidc"].(map[string]any) + if oidc["client_secret"] != "****" { + t.Errorf("oidc client_secret not redacted: got %v", oidc["client_secret"]) + } + + odoo, _ := resp["odoo"].(map[string]any) + if odoo["api_key"] != "****" { + t.Errorf("odoo api_key not redacted: got %v", odoo["api_key"]) + } +} + +func TestGetModuleSettings(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/jobs", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + if resp["directory"] != "/etc/silo/jobdefs" { + t.Errorf("jobs directory: got %v, want /etc/silo/jobdefs", resp["directory"]) + } + if resp["runner_timeout"] != float64(90) { + t.Errorf("runner_timeout: got %v, want 90", resp["runner_timeout"]) + } +} + +func TestGetModuleSettings_Unknown(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("GET", "/api/admin/settings/nonexistent", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestToggleModule(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + // Projects is enabled by default; disable it + body := `{"enabled": false}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/projects", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + updated, _ := resp["updated"].([]any) + if len(updated) != 1 || updated[0] != "projects.enabled" { + t.Errorf("updated: got %v, want [projects.enabled]", updated) + } + + // Verify registry state + if s.modules.IsEnabled("projects") { + t.Error("projects should be disabled after toggle") + } +} + +func TestToggleModule_DependencyError(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + // DAG depends on Jobs. Jobs is disabled by default. + // Enabling DAG without Jobs should fail. + body := `{"enabled": true}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/dag", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestToggleRequiredModule(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + body := `{"enabled": false}` + req := adminSettingsRequest(httptest.NewRequest("PUT", "/api/admin/settings/core", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestTestConnectivity_Database(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/database/test", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding: %v", err) + } + + if resp["success"] != true { + t.Errorf("expected success=true, got %v; message: %v", resp["success"], resp["message"]) + } + if resp["latency_ms"] == nil { + t.Error("expected latency_ms in response") + } +} + +func TestTestConnectivity_NotTestable(t *testing.T) { + s := newSettingsTestServer(t) + router := newSettingsRouter(s) + + req := adminSettingsRequest(httptest.NewRequest("POST", "/api/admin/settings/core/test", nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} diff --git a/internal/db/settings.go b/internal/db/settings.go new file mode 100644 index 0000000..b297774 --- /dev/null +++ b/internal/db/settings.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" +) + +// SettingsRepository provides access to module_state and settings_overrides tables. +type SettingsRepository struct { + db *DB +} + +// NewSettingsRepository creates a new SettingsRepository. +func NewSettingsRepository(db *DB) *SettingsRepository { + return &SettingsRepository{db: db} +} + +// GetModuleStates returns all module enabled/disabled states from the database. +func (r *SettingsRepository) GetModuleStates(ctx context.Context) (map[string]bool, error) { + rows, err := r.db.pool.Query(ctx, + `SELECT module_id, enabled FROM module_state`) + if err != nil { + return nil, fmt.Errorf("querying module states: %w", err) + } + defer rows.Close() + + states := make(map[string]bool) + for rows.Next() { + var id string + var enabled bool + if err := rows.Scan(&id, &enabled); err != nil { + return nil, fmt.Errorf("scanning module state: %w", err) + } + states[id] = enabled + } + return states, rows.Err() +} + +// SetModuleState persists a module's enabled state. Uses upsert semantics. +func (r *SettingsRepository) SetModuleState(ctx context.Context, moduleID string, enabled bool, updatedBy string) error { + _, err := r.db.pool.Exec(ctx, + `INSERT INTO module_state (module_id, enabled, updated_by, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (module_id) DO UPDATE + SET enabled = EXCLUDED.enabled, + updated_by = EXCLUDED.updated_by, + updated_at = now()`, + moduleID, enabled, updatedBy) + if err != nil { + return fmt.Errorf("setting module state: %w", err) + } + return nil +} + +// GetOverrides returns all settings overrides from the database. +func (r *SettingsRepository) GetOverrides(ctx context.Context) (map[string]json.RawMessage, error) { + rows, err := r.db.pool.Query(ctx, + `SELECT key, value FROM settings_overrides`) + if err != nil { + return nil, fmt.Errorf("querying settings overrides: %w", err) + } + defer rows.Close() + + overrides := make(map[string]json.RawMessage) + for rows.Next() { + var key string + var value json.RawMessage + if err := rows.Scan(&key, &value); err != nil { + return nil, fmt.Errorf("scanning settings override: %w", err) + } + overrides[key] = value + } + return overrides, rows.Err() +} + +// SetOverride persists a settings override. Uses upsert semantics. +func (r *SettingsRepository) SetOverride(ctx context.Context, key string, value any, updatedBy string) error { + jsonVal, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling override value: %w", err) + } + _, err = r.db.pool.Exec(ctx, + `INSERT INTO settings_overrides (key, value, updated_by, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_by = EXCLUDED.updated_by, + updated_at = now()`, + key, jsonVal, updatedBy) + if err != nil { + return fmt.Errorf("setting override: %w", err) + } + return nil +} + +// DeleteOverride removes a settings override. +func (r *SettingsRepository) DeleteOverride(ctx context.Context, key string) error { + _, err := r.db.pool.Exec(ctx, + `DELETE FROM settings_overrides WHERE key = $1`, key) + if err != nil { + return fmt.Errorf("deleting override: %w", err) + } + return nil +}