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
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
|
|
} |