package valueobjects import ( "fmt" "strings" "time" "knowfoolery/backend/shared/types" "knowfoolery/backend/shared/errors" ) // UserProfile represents a user's profile information type UserProfile struct { firstName string lastName string dateOfBirth *types.Date avatar *string bio string location string timezone string language string createdAt types.Timestamp updatedAt types.Timestamp } // NewUserProfile creates a new user profile func NewUserProfile( firstName string, lastName string, dateOfBirth *types.Date, avatar *string, ) (*UserProfile, error) { // Validate inputs if err := validateName(firstName, "first name"); err != nil { return nil, err } if err := validateName(lastName, "last name"); err != nil { return nil, err } if dateOfBirth != nil { if err := validateDateOfBirth(*dateOfBirth); err != nil { return nil, fmt.Errorf("invalid date of birth: %w", err) } } if avatar != nil { if err := validateAvatarURL(*avatar); err != nil { return nil, fmt.Errorf("invalid avatar URL: %w", err) } } now := types.NewTimestamp() profile := &UserProfile{ firstName: strings.TrimSpace(firstName), lastName: strings.TrimSpace(lastName), dateOfBirth: dateOfBirth, avatar: avatar, bio: "", location: "", timezone: "UTC", // Default timezone language: "en", // Default language createdAt: now, updatedAt: now, } return profile, nil } // NewUserProfileWithDetails creates a profile with additional details func NewUserProfileWithDetails( firstName string, lastName string, dateOfBirth *types.Date, avatar *string, bio string, location string, timezone string, language string, ) (*UserProfile, error) { profile, err := NewUserProfile(firstName, lastName, dateOfBirth, avatar) if err != nil { return nil, err } // Set additional details if err := profile.SetBio(bio); err != nil { return nil, fmt.Errorf("invalid bio: %w", err) } if err := profile.SetLocation(location); err != nil { return nil, fmt.Errorf("invalid location: %w", err) } if err := profile.SetTimezone(timezone); err != nil { return nil, fmt.Errorf("invalid timezone: %w", err) } if err := profile.SetLanguage(language); err != nil { return nil, fmt.Errorf("invalid language: %w", err) } return profile, nil } // Getters // FirstName returns the first name func (p *UserProfile) FirstName() string { return p.firstName } // LastName returns the last name func (p *UserProfile) LastName() string { return p.lastName } // FullName returns the full name func (p *UserProfile) FullName() string { parts := []string{} if p.firstName != "" { parts = append(parts, p.firstName) } if p.lastName != "" { parts = append(parts, p.lastName) } return strings.Join(parts, " ") } // DateOfBirth returns the date of birth func (p *UserProfile) DateOfBirth() *types.Date { return p.dateOfBirth } // Avatar returns the avatar URL func (p *UserProfile) Avatar() *string { return p.avatar } // Bio returns the bio func (p *UserProfile) Bio() string { return p.bio } // Location returns the location func (p *UserProfile) Location() string { return p.location } // Timezone returns the timezone func (p *UserProfile) Timezone() string { return p.timezone } // Language returns the language func (p *UserProfile) Language() string { return p.language } // CreatedAt returns the creation timestamp func (p *UserProfile) CreatedAt() types.Timestamp { return p.createdAt } // UpdatedAt returns the last update timestamp func (p *UserProfile) UpdatedAt() types.Timestamp { return p.updatedAt } // Business methods // UpdateName updates the first and last name func (p *UserProfile) UpdateName(firstName, lastName string) error { if err := validateName(firstName, "first name"); err != nil { return err } if err := validateName(lastName, "last name"); err != nil { return err } p.firstName = strings.TrimSpace(firstName) p.lastName = strings.TrimSpace(lastName) p.markUpdated() return nil } // SetDateOfBirth sets the date of birth func (p *UserProfile) SetDateOfBirth(dateOfBirth *types.Date) error { if dateOfBirth != nil { if err := validateDateOfBirth(*dateOfBirth); err != nil { return fmt.Errorf("invalid date of birth: %w", err) } } p.dateOfBirth = dateOfBirth p.markUpdated() return nil } // SetAvatar sets the avatar URL func (p *UserProfile) SetAvatar(avatar *string) error { if avatar != nil { if err := validateAvatarURL(*avatar); err != nil { return fmt.Errorf("invalid avatar URL: %w", err) } } p.avatar = avatar p.markUpdated() return nil } // SetBio sets the bio func (p *UserProfile) SetBio(bio string) error { bio = strings.TrimSpace(bio) if len(bio) > types.MaxBioLength { return errors.ErrValidationFailed("bio", fmt.Sprintf("bio too long (max %d characters)", types.MaxBioLength)) } p.bio = bio p.markUpdated() return nil } // SetLocation sets the location func (p *UserProfile) SetLocation(location string) error { location = strings.TrimSpace(location) if len(location) > types.MaxLocationLength { return errors.ErrValidationFailed("location", fmt.Sprintf("location too long (max %d characters)", types.MaxLocationLength)) } p.location = location p.markUpdated() return nil } // SetTimezone sets the timezone func (p *UserProfile) SetTimezone(timezone string) error { timezone = strings.TrimSpace(timezone) if timezone == "" { timezone = "UTC" } // Validate timezone if err := validateTimezone(timezone); err != nil { return fmt.Errorf("invalid timezone: %w", err) } p.timezone = timezone p.markUpdated() return nil } // SetLanguage sets the language func (p *UserProfile) SetLanguage(language string) error { language = strings.TrimSpace(language) if language == "" { language = "en" } // Validate language code if err := validateLanguageCode(language); err != nil { return fmt.Errorf("invalid language: %w", err) } p.language = language p.markUpdated() return nil } // Query methods // IsComplete returns true if the profile has essential information func (p *UserProfile) IsComplete() bool { return p.firstName != "" && p.lastName != "" } // HasAvatar returns true if the profile has an avatar func (p *UserProfile) HasAvatar() bool { return p.avatar != nil && *p.avatar != "" } // HasDateOfBirth returns true if date of birth is set func (p *UserProfile) HasDateOfBirth() bool { return p.dateOfBirth != nil } // GetAge returns the age if date of birth is available func (p *UserProfile) GetAge() *int { if p.dateOfBirth == nil { return nil } now := time.Now() birthDate := time.Time(*p.dateOfBirth) age := now.Year() - birthDate.Year() if now.YearDay() < birthDate.YearDay() { age-- } return &age } // IsAdult returns true if the user is 18 or older func (p *UserProfile) IsAdult() bool { age := p.GetAge() return age != nil && *age >= 18 } // GetDisplayName returns the best display name func (p *UserProfile) GetDisplayName() string { fullName := p.FullName() if fullName != "" { return fullName } if p.firstName != "" { return p.firstName } return "User" } // GetInitials returns the initials func (p *UserProfile) GetInitials() string { var initials []string if p.firstName != "" { initials = append(initials, string([]rune(p.firstName)[0])) } if p.lastName != "" { initials = append(initials, string([]rune(p.lastName)[0])) } return strings.ToUpper(strings.Join(initials, "")) } // Validation // Validate performs comprehensive validation func (p *UserProfile) Validate() *types.ValidationResult { result := types.NewValidationResult() // Validate names if err := validateName(p.firstName, "first name"); err != nil { result.AddErrorf("invalid first name: %v", err) } if err := validateName(p.lastName, "last name"); err != nil { result.AddErrorf("invalid last name: %v", err) } // Validate date of birth if p.dateOfBirth != nil { if err := validateDateOfBirth(*p.dateOfBirth); err != nil { result.AddErrorf("invalid date of birth: %v", err) } } // Validate avatar URL if p.avatar != nil { if err := validateAvatarURL(*p.avatar); err != nil { result.AddErrorf("invalid avatar URL: %v", err) } } // Validate bio length if len(p.bio) > types.MaxBioLength { result.AddErrorf("bio too long (max %d characters)", types.MaxBioLength) } // Validate location length if len(p.location) > types.MaxLocationLength { result.AddErrorf("location too long (max %d characters)", types.MaxLocationLength) } // Validate timezone if err := validateTimezone(p.timezone); err != nil { result.AddErrorf("invalid timezone: %v", err) } // Validate language if err := validateLanguageCode(p.language); err != nil { result.AddErrorf("invalid language: %v", err) } return result } // Helper methods // markUpdated updates the timestamp func (p *UserProfile) markUpdated() { p.updatedAt = types.NewTimestamp() } // ToSnapshot creates a read-only snapshot func (p *UserProfile) ToSnapshot() *UserProfileSnapshot { var avatar *string if p.avatar != nil { avatarCopy := *p.avatar avatar = &avatarCopy } var dateOfBirth *types.Date if p.dateOfBirth != nil { dobCopy := *p.dateOfBirth dateOfBirth = &dobCopy } return &UserProfileSnapshot{ FirstName: p.firstName, LastName: p.lastName, FullName: p.FullName(), DateOfBirth: dateOfBirth, Avatar: avatar, Bio: p.bio, Location: p.location, Timezone: p.timezone, Language: p.language, CreatedAt: p.createdAt, UpdatedAt: p.updatedAt, } } // UserProfileSnapshot represents a read-only view of user profile data type UserProfileSnapshot struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` FullName string `json:"full_name"` DateOfBirth *types.Date `json:"date_of_birth,omitempty"` Avatar *string `json:"avatar,omitempty"` Bio string `json:"bio"` Location string `json:"location"` Timezone string `json:"timezone"` Language string `json:"language"` CreatedAt types.Timestamp `json:"created_at"` UpdatedAt types.Timestamp `json:"updated_at"` } // GetAge returns the age from the snapshot func (ps *UserProfileSnapshot) GetAge() *int { if ps.DateOfBirth == nil { return nil } now := time.Now() birthDate := time.Time(*ps.DateOfBirth) age := now.Year() - birthDate.Year() if now.YearDay() < birthDate.YearDay() { age-- } return &age } // GetInitials returns initials from the snapshot func (ps *UserProfileSnapshot) GetInitials() string { var initials []string if ps.FirstName != "" { initials = append(initials, string([]rune(ps.FirstName)[0])) } if ps.LastName != "" { initials = append(initials, string([]rune(ps.LastName)[0])) } return strings.ToUpper(strings.Join(initials, "")) } // Validation helper functions func validateName(name, fieldName string) error { name = strings.TrimSpace(name) // Names can be empty (not required) if name == "" { return nil } if len(name) > types.MaxNameLength { return errors.ErrValidationFailed(fieldName, fmt.Sprintf("%s too long (max %d characters)", fieldName, types.MaxNameLength)) } // Check for valid characters (letters, spaces, hyphens, apostrophes) for _, char := range name { if !isValidNameChar(char) { return errors.ErrValidationFailed(fieldName, fmt.Sprintf("%s contains invalid characters", fieldName)) } } return nil } func isValidNameChar(char rune) bool { // Allow letters, spaces, hyphens, and apostrophes return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= 'À' && char <= 'ÿ') || // Extended Latin characters char == ' ' || char == '-' || char == '\'' || char == '.' } func validateDateOfBirth(dateOfBirth types.Date) error { birthDate := time.Time(dateOfBirth) now := time.Now() // Check if date is in the future if birthDate.After(now) { return errors.ErrValidationFailed("date_of_birth", "date of birth cannot be in the future") } // Check if date is too old (older than 120 years) minDate := now.AddDate(-120, 0, 0) if birthDate.Before(minDate) { return errors.ErrValidationFailed("date_of_birth", "date of birth is too old") } return nil } func validateAvatarURL(url string) error { url = strings.TrimSpace(url) if url == "" { return nil // Empty is allowed } if len(url) > types.MaxURLLength { return errors.ErrValidationFailed("avatar", fmt.Sprintf("avatar URL too long (max %d characters)", types.MaxURLLength)) } // Basic URL validation if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { return errors.ErrValidationFailed("avatar", "avatar URL must start with http:// or https://") } return nil } func validateTimezone(timezone string) error { if timezone == "" { return nil // Empty defaults to UTC } // Try to load the timezone to validate it _, err := time.LoadLocation(timezone) if err != nil { return errors.ErrValidationFailed("timezone", "invalid timezone") } return nil } func validateLanguageCode(language string) error { if language == "" { return nil // Empty defaults to "en" } // Validate language code format (ISO 639-1) if len(language) != 2 { return errors.ErrValidationFailed("language", "language code must be 2 characters (ISO 639-1)") } // Check if it's lowercase letters for _, char := range language { if char < 'a' || char > 'z' { return errors.ErrValidationFailed("language", "language code must be lowercase letters") } } // Could validate against a list of known language codes // For now, just check format return nil }