Files
GraceSolutions 99a0589a2c feat(settings): add CRUD API for the app_settings key-value store
New SettingsService provides List/Get/Upsert/Delete over the app_settings table. SettingsHandler exposes GET /api/v1/settings, GET/PUT/DELETE /api/v1/settings/{key} and redacts sensitive values in responses. Changes are recorded as ConfigChange audit events. The service is wired into server.Dependencies and constructed in cli.RunForeground.
2026-04-23 13:19:18 -04:00

134 lines
3.2 KiB
Go

// Package services - application settings (app_settings) service
package services
import (
"database/sql"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
// Setting represents a single application setting row.
type Setting struct {
Key string
Value string
ValueType string
Description *string
IsSensitive bool
UpdatedUTC time.Time
}
// SettingsService provides CRUD access to the app_settings table.
type SettingsService struct {
db *sql.DB
logger *logging.Logger
}
// NewSettingsService creates a new SettingsService
func NewSettingsService(db *sql.DB, logger *logging.Logger) *SettingsService {
return &SettingsService{db: db, logger: logger}
}
// List returns all application settings ordered by key.
func (s *SettingsService) List() ([]Setting, error) {
rows, err := s.db.Query(`
SELECT key, value, value_type, description, is_sensitive, updated_utc
FROM app_settings
ORDER BY key
`)
if err != nil {
return nil, err
}
defer rows.Close()
settings := make([]Setting, 0)
for rows.Next() {
setting, err := scanSetting(rows)
if err != nil {
return nil, err
}
settings = append(settings, setting)
}
return settings, rows.Err()
}
// Get returns a single setting by key, or nil if it does not exist.
func (s *SettingsService) Get(key string) (*Setting, error) {
row := s.db.QueryRow(`
SELECT key, value, value_type, description, is_sensitive, updated_utc
FROM app_settings WHERE key = ?
`, key)
setting, err := scanSetting(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &setting, nil
}
// Upsert inserts or updates a setting. When description or valueType is empty
// on update the existing values are preserved.
func (s *SettingsService) Upsert(setting Setting) error {
if setting.ValueType == "" {
setting.ValueType = "string"
}
setting.UpdatedUTC = time.Now().UTC()
_, err := s.db.Exec(`
INSERT INTO app_settings (key, value, value_type, description, is_sensitive, updated_utc)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
value_type = excluded.value_type,
description = COALESCE(excluded.description, app_settings.description),
is_sensitive = excluded.is_sensitive,
updated_utc = excluded.updated_utc
`,
setting.Key, setting.Value, setting.ValueType,
setting.Description, boolToInt(setting.IsSensitive),
setting.UpdatedUTC.Format(time.RFC3339),
)
return err
}
// Delete removes a setting by key.
func (s *SettingsService) Delete(key string) error {
_, err := s.db.Exec(`DELETE FROM app_settings WHERE key = ?`, key)
return err
}
type settingScanner interface {
Scan(dest ...any) error
}
func scanSetting(row settingScanner) (Setting, error) {
var setting Setting
var description sql.NullString
var updatedUTC string
var isSensitive int
if err := row.Scan(
&setting.Key, &setting.Value, &setting.ValueType,
&description, &isSensitive, &updatedUTC,
); err != nil {
return Setting{}, err
}
if description.Valid {
v := description.String
setting.Description = &v
}
setting.IsSensitive = isSensitive == 1
if t, err := time.Parse(time.RFC3339, updatedUTC); err == nil {
setting.UpdatedUTC = t
}
return setting, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}