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.
512 lines
12 KiB
Go
512 lines
12 KiB
Go
package valueobjects
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"knowfoolery/backend/shared/types"
|
|
"knowfoolery/backend/shared/errors"
|
|
)
|
|
|
|
// Theme represents a question theme/category
|
|
type Theme struct {
|
|
id types.ThemeID
|
|
name string
|
|
description string
|
|
color string // Hex color code for UI
|
|
icon string // Icon identifier
|
|
parentID *types.ThemeID // For hierarchical themes
|
|
isActive bool
|
|
sortOrder int
|
|
createdAt types.Timestamp
|
|
updatedAt types.Timestamp
|
|
}
|
|
|
|
// NewTheme creates a new theme
|
|
func NewTheme(
|
|
name string,
|
|
description string,
|
|
) (*Theme, error) {
|
|
// Validate inputs
|
|
if err := validateThemeName(name); err != nil {
|
|
return nil, fmt.Errorf("invalid theme name: %w", err)
|
|
}
|
|
|
|
if err := validateThemeDescription(description); err != nil {
|
|
return nil, fmt.Errorf("invalid theme description: %w", err)
|
|
}
|
|
|
|
now := types.NewTimestamp()
|
|
|
|
theme := &Theme{
|
|
id: types.NewThemeID(),
|
|
name: strings.TrimSpace(name),
|
|
description: strings.TrimSpace(description),
|
|
color: types.DefaultThemeColor,
|
|
icon: types.DefaultThemeIcon,
|
|
parentID: nil,
|
|
isActive: true,
|
|
sortOrder: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
|
|
return theme, nil
|
|
}
|
|
|
|
// NewThemeWithID creates a theme with a specific ID (for loading from persistence)
|
|
func NewThemeWithID(
|
|
id types.ThemeID,
|
|
name string,
|
|
description string,
|
|
createdAt types.Timestamp,
|
|
) (*Theme, error) {
|
|
if id.IsEmpty() {
|
|
return nil, errors.ErrValidationFailed("id", "theme ID cannot be empty")
|
|
}
|
|
|
|
theme, err := NewTheme(name, description)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
theme.id = id
|
|
theme.createdAt = createdAt
|
|
theme.updatedAt = createdAt
|
|
|
|
return theme, nil
|
|
}
|
|
|
|
// Predefined themes factory functions
|
|
|
|
// NewGeographyTheme creates a geography theme
|
|
func NewGeographyTheme() *Theme {
|
|
theme, _ := NewTheme("Geography", "Questions about countries, capitals, landmarks, and physical geography")
|
|
theme.color = "#4CAF50"
|
|
theme.icon = "globe"
|
|
return theme
|
|
}
|
|
|
|
// NewHistoryTheme creates a history theme
|
|
func NewHistoryTheme() *Theme {
|
|
theme, _ := NewTheme("History", "Questions about historical events, figures, and periods")
|
|
theme.color = "#795548"
|
|
theme.icon = "history"
|
|
return theme
|
|
}
|
|
|
|
// NewScienceTheme creates a science theme
|
|
func NewScienceTheme() *Theme {
|
|
theme, _ := NewTheme("Science", "Questions about biology, chemistry, physics, and general science")
|
|
theme.color = "#2196F3"
|
|
theme.icon = "science"
|
|
return theme
|
|
}
|
|
|
|
// NewSportsTheme creates a sports theme
|
|
func NewSportsTheme() *Theme {
|
|
theme, _ := NewTheme("Sports", "Questions about sports, athletes, and sporting events")
|
|
theme.color = "#FF5722"
|
|
theme.icon = "sports"
|
|
return theme
|
|
}
|
|
|
|
// NewEntertainmentTheme creates an entertainment theme
|
|
func NewEntertainmentTheme() *Theme {
|
|
theme, _ := NewTheme("Entertainment", "Questions about movies, music, TV shows, and celebrities")
|
|
theme.color = "#E91E63"
|
|
theme.icon = "movie"
|
|
return theme
|
|
}
|
|
|
|
// NewLiteratureTheme creates a literature theme
|
|
func NewLiteratureTheme() *Theme {
|
|
theme, _ := NewTheme("Literature", "Questions about books, authors, and literary works")
|
|
theme.color = "#9C27B0"
|
|
theme.icon = "book"
|
|
return theme
|
|
}
|
|
|
|
// NewGeneralKnowledgeTheme creates a general knowledge theme
|
|
func NewGeneralKnowledgeTheme() *Theme {
|
|
theme, _ := NewTheme("General Knowledge", "Miscellaneous questions covering various topics")
|
|
theme.color = "#607D8B"
|
|
theme.icon = "lightbulb"
|
|
return theme
|
|
}
|
|
|
|
// Getters
|
|
|
|
// ID returns the theme's unique identifier
|
|
func (t *Theme) ID() types.ThemeID {
|
|
return t.id
|
|
}
|
|
|
|
// Name returns the theme name
|
|
func (t *Theme) Name() string {
|
|
return t.name
|
|
}
|
|
|
|
// Description returns the theme description
|
|
func (t *Theme) Description() string {
|
|
return t.description
|
|
}
|
|
|
|
// Color returns the theme color
|
|
func (t *Theme) Color() string {
|
|
return t.color
|
|
}
|
|
|
|
// Icon returns the theme icon
|
|
func (t *Theme) Icon() string {
|
|
return t.icon
|
|
}
|
|
|
|
// ParentID returns the parent theme ID if this is a sub-theme
|
|
func (t *Theme) ParentID() *types.ThemeID {
|
|
return t.parentID
|
|
}
|
|
|
|
// IsActive returns true if the theme is active
|
|
func (t *Theme) IsActive() bool {
|
|
return t.isActive
|
|
}
|
|
|
|
// SortOrder returns the sort order
|
|
func (t *Theme) SortOrder() int {
|
|
return t.sortOrder
|
|
}
|
|
|
|
// CreatedAt returns the creation timestamp
|
|
func (t *Theme) CreatedAt() types.Timestamp {
|
|
return t.createdAt
|
|
}
|
|
|
|
// UpdatedAt returns the last update timestamp
|
|
func (t *Theme) UpdatedAt() types.Timestamp {
|
|
return t.updatedAt
|
|
}
|
|
|
|
// Business methods
|
|
|
|
// UpdateName updates the theme name
|
|
func (t *Theme) UpdateName(newName string) error {
|
|
if err := validateThemeName(newName); err != nil {
|
|
return fmt.Errorf("invalid theme name: %w", err)
|
|
}
|
|
|
|
t.name = strings.TrimSpace(newName)
|
|
t.markUpdated()
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateDescription updates the theme description
|
|
func (t *Theme) UpdateDescription(newDescription string) error {
|
|
if err := validateThemeDescription(newDescription); err != nil {
|
|
return fmt.Errorf("invalid theme description: %w", err)
|
|
}
|
|
|
|
t.description = strings.TrimSpace(newDescription)
|
|
t.markUpdated()
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetColor sets the theme color
|
|
func (t *Theme) SetColor(color string) error {
|
|
color = strings.TrimSpace(color)
|
|
|
|
if color == "" {
|
|
t.color = types.DefaultThemeColor
|
|
} else {
|
|
if err := validateHexColor(color); err != nil {
|
|
return fmt.Errorf("invalid color: %w", err)
|
|
}
|
|
t.color = color
|
|
}
|
|
|
|
t.markUpdated()
|
|
return nil
|
|
}
|
|
|
|
// SetIcon sets the theme icon
|
|
func (t *Theme) SetIcon(icon string) error {
|
|
icon = strings.TrimSpace(icon)
|
|
|
|
if icon == "" {
|
|
t.icon = types.DefaultThemeIcon
|
|
} else {
|
|
if len(icon) > types.MaxIconNameLength {
|
|
return errors.ErrValidationFailed("icon", fmt.Sprintf("icon name too long (max %d characters)", types.MaxIconNameLength))
|
|
}
|
|
t.icon = icon
|
|
}
|
|
|
|
t.markUpdated()
|
|
return nil
|
|
}
|
|
|
|
// SetParent sets the parent theme
|
|
func (t *Theme) SetParent(parentID *types.ThemeID) error {
|
|
// Prevent circular references
|
|
if parentID != nil && *parentID == t.id {
|
|
return errors.ErrValidationFailed("parent_id", "theme cannot be its own parent")
|
|
}
|
|
|
|
t.parentID = parentID
|
|
t.markUpdated()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Activate activates the theme
|
|
func (t *Theme) Activate() {
|
|
if !t.isActive {
|
|
t.isActive = true
|
|
t.markUpdated()
|
|
}
|
|
}
|
|
|
|
// Deactivate deactivates the theme
|
|
func (t *Theme) Deactivate() {
|
|
if t.isActive {
|
|
t.isActive = false
|
|
t.markUpdated()
|
|
}
|
|
}
|
|
|
|
// SetSortOrder sets the sort order
|
|
func (t *Theme) SetSortOrder(order int) {
|
|
if order < 0 {
|
|
order = 0
|
|
}
|
|
|
|
if t.sortOrder != order {
|
|
t.sortOrder = order
|
|
t.markUpdated()
|
|
}
|
|
}
|
|
|
|
// Query methods
|
|
|
|
// IsRootTheme returns true if this is a root theme (no parent)
|
|
func (t *Theme) IsRootTheme() bool {
|
|
return t.parentID == nil
|
|
}
|
|
|
|
// IsSubTheme returns true if this is a sub-theme (has parent)
|
|
func (t *Theme) IsSubTheme() bool {
|
|
return t.parentID != nil
|
|
}
|
|
|
|
// HasParent checks if this theme has the specified parent
|
|
func (t *Theme) HasParent(parentID types.ThemeID) bool {
|
|
return t.parentID != nil && *t.parentID == parentID
|
|
}
|
|
|
|
// Equals checks if two themes are equal
|
|
func (t *Theme) Equals(other *Theme) bool {
|
|
if other == nil {
|
|
return false
|
|
}
|
|
return t.id == other.id
|
|
}
|
|
|
|
// Validation methods
|
|
|
|
// Validate performs comprehensive validation
|
|
func (t *Theme) Validate() *types.ValidationResult {
|
|
result := types.NewValidationResult()
|
|
|
|
// Validate ID
|
|
if t.id.IsEmpty() {
|
|
result.AddError("theme ID cannot be empty")
|
|
}
|
|
|
|
// Validate name
|
|
if err := validateThemeName(t.name); err != nil {
|
|
result.AddErrorf("invalid name: %v", err)
|
|
}
|
|
|
|
// Validate description
|
|
if err := validateThemeDescription(t.description); err != nil {
|
|
result.AddErrorf("invalid description: %v", err)
|
|
}
|
|
|
|
// Validate color
|
|
if err := validateHexColor(t.color); err != nil {
|
|
result.AddErrorf("invalid color: %v", err)
|
|
}
|
|
|
|
// Validate icon
|
|
if len(t.icon) > types.MaxIconNameLength {
|
|
result.AddErrorf("icon name too long (max %d characters)", types.MaxIconNameLength)
|
|
}
|
|
|
|
// Validate sort order
|
|
if t.sortOrder < 0 {
|
|
result.AddError("sort order cannot be negative")
|
|
}
|
|
|
|
// Validate parent relationship
|
|
if t.parentID != nil && *t.parentID == t.id {
|
|
result.AddError("theme cannot be its own parent")
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
// markUpdated updates the timestamp
|
|
func (t *Theme) markUpdated() {
|
|
t.updatedAt = types.NewTimestamp()
|
|
}
|
|
|
|
// ToSnapshot creates a read-only snapshot of the theme
|
|
func (t *Theme) ToSnapshot() *ThemeSnapshot {
|
|
return &ThemeSnapshot{
|
|
ID: t.id,
|
|
Name: t.name,
|
|
Description: t.description,
|
|
Color: t.color,
|
|
Icon: t.icon,
|
|
ParentID: t.parentID,
|
|
IsActive: t.isActive,
|
|
SortOrder: t.sortOrder,
|
|
CreatedAt: t.createdAt,
|
|
UpdatedAt: t.updatedAt,
|
|
}
|
|
}
|
|
|
|
// ToDisplayName returns a formatted display name
|
|
func (t *Theme) ToDisplayName() string {
|
|
return t.name
|
|
}
|
|
|
|
// String returns a string representation of the theme
|
|
func (t *Theme) String() string {
|
|
if t.IsSubTheme() {
|
|
return fmt.Sprintf("%s (sub-theme)", t.name)
|
|
}
|
|
return t.name
|
|
}
|
|
|
|
// ThemeSnapshot represents a read-only view of theme data
|
|
type ThemeSnapshot struct {
|
|
ID types.ThemeID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
ParentID *types.ThemeID `json:"parent_id,omitempty"`
|
|
IsActive bool `json:"is_active"`
|
|
SortOrder int `json:"sort_order"`
|
|
CreatedAt types.Timestamp `json:"created_at"`
|
|
UpdatedAt types.Timestamp `json:"updated_at"`
|
|
}
|
|
|
|
// GetDisplayName returns the display name
|
|
func (ts *ThemeSnapshot) GetDisplayName() string {
|
|
return ts.Name
|
|
}
|
|
|
|
// IsSubTheme returns true if this is a sub-theme
|
|
func (ts *ThemeSnapshot) IsSubTheme() bool {
|
|
return ts.ParentID != nil
|
|
}
|
|
|
|
// Validation helper functions
|
|
|
|
func validateThemeName(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return errors.ErrValidationFailed("name", "theme name cannot be empty")
|
|
}
|
|
|
|
if len(name) < types.MinThemeNameLength {
|
|
return errors.ErrValidationFailed("name", fmt.Sprintf("theme name too short (min %d characters)", types.MinThemeNameLength))
|
|
}
|
|
|
|
if len(name) > types.MaxThemeNameLength {
|
|
return errors.ErrValidationFailed("name", fmt.Sprintf("theme name too long (max %d characters)", types.MaxThemeNameLength))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateThemeDescription(description string) error {
|
|
description = strings.TrimSpace(description)
|
|
if description == "" {
|
|
return errors.ErrValidationFailed("description", "theme description cannot be empty")
|
|
}
|
|
|
|
if len(description) < types.MinThemeDescriptionLength {
|
|
return errors.ErrValidationFailed("description", fmt.Sprintf("theme description too short (min %d characters)", types.MinThemeDescriptionLength))
|
|
}
|
|
|
|
if len(description) > types.MaxThemeDescriptionLength {
|
|
return errors.ErrValidationFailed("description", fmt.Sprintf("theme description too long (max %d characters)", types.MaxThemeDescriptionLength))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateHexColor(color string) error {
|
|
if color == "" {
|
|
return nil // Empty is allowed, will use default
|
|
}
|
|
|
|
if !strings.HasPrefix(color, "#") {
|
|
return errors.ErrValidationFailed("color", "color must start with #")
|
|
}
|
|
|
|
if len(color) != 7 {
|
|
return errors.ErrValidationFailed("color", "color must be in format #RRGGBB")
|
|
}
|
|
|
|
// Check if all characters after # are valid hex
|
|
for i, char := range color[1:] {
|
|
if !isHexChar(char) {
|
|
return errors.ErrValidationFailed("color", fmt.Sprintf("invalid hex character at position %d", i+1))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isHexChar(char rune) bool {
|
|
return (char >= '0' && char <= '9') ||
|
|
(char >= 'A' && char <= 'F') ||
|
|
(char >= 'a' && char <= 'f')
|
|
}
|
|
|
|
// Theme collections for easy access
|
|
|
|
// GetDefaultThemes returns a list of default themes
|
|
func GetDefaultThemes() []*Theme {
|
|
return []*Theme{
|
|
NewGeographyTheme(),
|
|
NewHistoryTheme(),
|
|
NewScienceTheme(),
|
|
NewSportsTheme(),
|
|
NewEntertainmentTheme(),
|
|
NewLiteratureTheme(),
|
|
NewGeneralKnowledgeTheme(),
|
|
}
|
|
}
|
|
|
|
// GetThemeByName returns a default theme by name
|
|
func GetThemeByName(name string) *Theme {
|
|
themes := GetDefaultThemes()
|
|
name = strings.TrimSpace(strings.ToLower(name))
|
|
|
|
for _, theme := range themes {
|
|
if strings.ToLower(theme.Name()) == name {
|
|
return theme
|
|
}
|
|
}
|
|
|
|
return nil
|
|
} |