package domain import ( "encoding/json" "time" "unicode/utf8" ) // UserID is a strongly-typed identifier for users. type UserID string // String returns the string representation of the ID. func (id UserID) String() string { return string(id) } // ValidThemes lists the allowed theme values. var ValidThemes = []string{"light", "dark", "system"} // NotificationPreferences controls notification delivery channels. type NotificationPreferences struct { Email bool `json:"email"` Push bool `json:"push"` SMS bool `json:"sms"` } // Preferences holds a user's preference settings. // Known fields are typed; unknown keys are preserved in Extra for extensibility. type Preferences struct { Theme string `json:"theme"` Language string `json:"language"` Notifications NotificationPreferences `json:"notifications"` Extra map[string]any `json:"-"` } // MarshalJSON merges known fields and Extra into a single JSON object. func (p Preferences) MarshalJSON() ([]byte, error) { // Build a map with known fields m := make(map[string]any) // Copy extra fields first (so known fields override if there's overlap) for k, v := range p.Extra { m[k] = v } m["theme"] = p.Theme m["language"] = p.Language m["notifications"] = p.Notifications return json.Marshal(m) } // UnmarshalJSON decodes known fields into struct fields and captures unknown keys in Extra. func (p *Preferences) UnmarshalJSON(data []byte) error { // Decode known fields via an alias to avoid recursion type Alias Preferences var alias Alias if err := json.Unmarshal(data, &alias); err != nil { return err } // Decode all fields into a map to find unknown keys var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } // Copy known fields p.Theme = alias.Theme p.Language = alias.Language p.Notifications = alias.Notifications // Capture unknown keys knownKeys := map[string]bool{ "theme": true, "language": true, "notifications": true, } p.Extra = nil for k, v := range raw { if knownKeys[k] { continue } if p.Extra == nil { p.Extra = make(map[string]any) } var val any if err := json.Unmarshal(v, &val); err != nil { return err } p.Extra[k] = val } return nil } // MaxLanguageLen is the maximum rune length for a language tag. const MaxLanguageLen = 10 // Validate checks the known preference fields. // Returns ErrInvalidTheme if theme is not a valid value. // Returns ErrInvalidLanguage if language exceeds MaxLanguageLen runes. func (p *Preferences) Validate() error { if p.Theme != "" { valid := false for _, t := range ValidThemes { if p.Theme == t { valid = true break } } if !valid { return ErrInvalidTheme } } if utf8.RuneCountInString(p.Language) > MaxLanguageLen { return ErrInvalidLanguage } return nil } // DefaultPreferences returns the default preference values. func DefaultPreferences() Preferences { return Preferences{ Theme: "system", Language: "en", Notifications: NotificationPreferences{ Email: true, Push: true, SMS: false, }, } } // UserPreferences is the aggregate associating preferences with a user. type UserPreferences struct { UserID UserID Preferences Preferences UpdatedAt time.Time }