You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

596 lines
14 KiB
Go

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
}