From 9e1a50876999d5304654de476d61424da502530c Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Tue, 21 Apr 2026 11:08:06 +0300 Subject: [PATCH] feat: Add configuration store service with database-backed runtime settings, validation, and sensitive value masking - Add ConfigStoreService struct with database dependency and NewConfigStoreService constructor - Add List method with optional section filtering, section/key ordering, and sensitive value masking (********) - Add UpsertConfigInput struct with section/key/value/type/sensitive/description/updated_by fields and required validation - Add Upsert method with section+key lookup, default --- .../internal/services/config_store.go | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 apps/api-server/internal/services/config_store.go diff --git a/apps/api-server/internal/services/config_store.go b/apps/api-server/internal/services/config_store.go new file mode 100644 index 0000000..8423dea --- /dev/null +++ b/apps/api-server/internal/services/config_store.go @@ -0,0 +1,136 @@ +package services + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// ConfigStoreService provides persisted runtime-tunable configuration entries +// stored in the database. The static `internal/config` package continues to +// provide startup/bootstrap configuration; this service complements it. +type ConfigStoreService struct { + db *database.Database +} + +func NewConfigStoreService(db *database.Database) *ConfigStoreService { + return &ConfigStoreService{db: db} +} + +func (s *ConfigStoreService) List(ctx context.Context, section string) ([]models.ConfigEntry, error) { + q := s.db.DB.WithContext(ctx).Model(&models.ConfigEntry{}) + if section != "" { + q = q.Where("section = ?", section) + } + var out []models.ConfigEntry + if err := q.Order("section, key").Find(&out).Error; err != nil { + return nil, err + } + // Mask sensitive values + for i := range out { + if out[i].Sensitive { + out[i].Value = "********" + } + } + return out, nil +} + +type UpsertConfigInput struct { + Section string `json:"section" binding:"required"` + Key string `json:"key" binding:"required"` + Value string `json:"value"` + Type string `json:"type"` + Sensitive bool `json:"sensitive"` + Description string `json:"description"` + UpdatedBy string `json:"updated_by"` +} + +func (s *ConfigStoreService) Upsert(ctx context.Context, in UpsertConfigInput) (*models.ConfigEntry, error) { + var e models.ConfigEntry + err := s.db.DB.WithContext(ctx). + Where("section = ? AND key = ?", in.Section, in.Key). + First(&e).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + e.Section = in.Section + e.Key = in.Key + e.Value = in.Value + e.Type = defaultStr(in.Type, "string") + e.Sensitive = in.Sensitive + e.Description = in.Description + e.UpdatedBy = in.UpdatedBy + if e.ID == 0 { + e.CreatedAt = time.Now() + } + e.UpdatedAt = time.Now() + if err := s.db.DB.WithContext(ctx).Save(&e).Error; err != nil { + return nil, err + } + return &e, nil +} + +// Validate runs type/constraint checks across all entries (or a single section). +func (s *ConfigStoreService) Validate(ctx context.Context, section string) (*ConfigValidationResult, error) { + entries, err := s.List(ctx, section) + if err != nil { + return nil, err + } + res := &ConfigValidationResult{Valid: true} + for _, e := range entries { + switch e.Type { + case "integer": + if _, err := strconv.ParseInt(e.Value, 10, 64); err != nil && e.Value != "" { + res.Valid = false + res.Errors = append(res.Errors, ConfigIssue{ + Section: e.Section, Key: e.Key, + Message: fmt.Sprintf("value %q is not a valid integer", e.Value), + Code: "INVALID_INTEGER", + }) + } + case "boolean": + if e.Value != "" { + if _, err := strconv.ParseBool(e.Value); err != nil { + res.Valid = false + res.Errors = append(res.Errors, ConfigIssue{ + Section: e.Section, Key: e.Key, + Message: fmt.Sprintf("value %q is not a valid boolean", e.Value), + Code: "INVALID_BOOLEAN", + }) + } + } + case "duration": + if e.Value != "" { + if _, err := time.ParseDuration(e.Value); err != nil { + res.Valid = false + res.Errors = append(res.Errors, ConfigIssue{ + Section: e.Section, Key: e.Key, + Message: fmt.Sprintf("value %q is not a valid duration", e.Value), + Code: "INVALID_DURATION", + }) + } + } + } + } + return res, nil +} + +type ConfigValidationResult struct { + Valid bool `json:"valid"` + Errors []ConfigIssue `json:"errors"` + Warnings []ConfigIssue `json:"warnings"` +} + +type ConfigIssue struct { + Section string `json:"section"` + Key string `json:"key"` + Message string `json:"message"` + Code string `json:"code"` +}