package service import ( "context" "fmt" "time" "golang.org/x/text/language" "git.threesix.ai/jordan/slack5-1770606136/pkg/logging" "git.threesix.ai/jordan/slack5-1770606136/services/preferences-api/internal/domain" "git.threesix.ai/jordan/slack5-1770606136/services/preferences-api/internal/port" ) // ValidationError carries per-field validation details. // It wraps domain.ErrInvalidPreferenceValue for errors.Is() matching. type ValidationError struct { Details map[string]string } func (e *ValidationError) Error() string { return "invalid preference values" } func (e *ValidationError) Unwrap() error { return domain.ErrInvalidPreferenceValue } // validThemes is the set of allowed values for the "theme" preference. var validThemes = map[string]bool{ "light": true, "dark": true, "system": true, } // PreferenceService handles preference-related business logic. type PreferenceService struct { repo port.PreferenceRepository logger *logging.Logger } // NewPreferenceService creates a new preference service. func NewPreferenceService(repo port.PreferenceRepository, logger *logging.Logger) *PreferenceService { return &PreferenceService{ repo: repo, logger: logger.WithService("PreferenceService"), } } // Get returns preferences for a user. // Returns nil when no preferences exist. func (s *PreferenceService) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) { return s.repo.Get(ctx, userID) } // UpsertInput contains the data needed to upsert preferences. type UpsertInput struct { UserID string Preferences map[string]any } // Upsert validates and stores preferences for a user. // Known keys are validated; unknown keys are accepted as-is. // Incoming keys are merged on top of existing preferences. func (s *PreferenceService) Upsert(ctx context.Context, input UpsertInput) (*domain.UserPreferences, error) { // Validate known preference keys if err := validatePreferences(input.Preferences); err != nil { return nil, err } // Fetch existing preferences for merge existing, err := s.repo.Get(ctx, input.UserID) if err != nil { return nil, err } merged := make(map[string]any) if existing != nil { for k, v := range existing.Preferences { merged[k] = v } } for k, v := range input.Preferences { merged[k] = v } now := time.Now().UTC() prefs := &domain.UserPreferences{ UserID: input.UserID, Preferences: merged, UpdatedAt: now, } if existing != nil { prefs.CreatedAt = existing.CreatedAt } else { prefs.CreatedAt = now } if err := s.repo.Upsert(ctx, prefs); err != nil { return nil, err } s.logger.Info("preferences upserted", "user_id", input.UserID) return prefs, nil } // validatePreferences checks known preference keys against their allowed values. func validatePreferences(prefs map[string]any) error { details := make(map[string]string) for key, value := range prefs { switch key { case "theme": s, ok := value.(string) if !ok || !validThemes[s] { details[key] = "must be one of: light, dark, system" } case "language": s, ok := value.(string) if !ok { details[key] = "must be a valid BCP-47 language tag" continue } if _, err := language.Parse(s); err != nil { details[key] = fmt.Sprintf("must be a valid BCP-47 language tag") } case "notifications_enabled": if _, ok := value.(bool); !ok { details[key] = "must be a boolean" } } } if len(details) > 0 { return &ValidationError{Details: details} } return nil }