package question import ( "fmt" "strings" "time" "knowfoolery/backend/shared/types" "knowfoolery/backend/shared/errors" "knowfoolery/backend/services/question-bank-service/internal/domain/valueobjects" ) // Question represents a quiz question aggregate type Question struct { // Identity id types.QuestionID // Core attributes questionText string correctAnswer string hints []string difficulty types.DifficultyLevel theme *valueobjects.Theme // Metadata tags []string explanation string sourceURL *string // Alternative answers (for fuzzy matching) alternativeAnswers []string // Usage statistics statistics *valueobjects.QuestionStatistics // Lifecycle status types.QuestionStatus createdAt types.Timestamp updatedAt types.Timestamp createdBy types.UserID // Version control version int // Validation rules isActive bool } // NewQuestion creates a new question func NewQuestion( questionText string, correctAnswer string, difficulty types.DifficultyLevel, theme *valueobjects.Theme, createdBy types.UserID, ) (*Question, error) { // Validate inputs if err := validateQuestionText(questionText); err != nil { return nil, fmt.Errorf("invalid question text: %w", err) } if err := validateCorrectAnswer(correctAnswer); err != nil { return nil, fmt.Errorf("invalid correct answer: %w", err) } if theme == nil { return nil, errors.ErrValidationFailed("theme", "theme cannot be nil") } if createdBy.IsEmpty() { return nil, errors.ErrValidationFailed("createdBy", "creator user ID cannot be empty") } now := types.NewTimestamp() question := &Question{ id: types.NewQuestionID(), questionText: strings.TrimSpace(questionText), correctAnswer: strings.TrimSpace(correctAnswer), hints: make([]string, 0), difficulty: difficulty, theme: theme, tags: make([]string, 0), explanation: "", sourceURL: nil, alternativeAnswers: make([]string, 0), statistics: valueobjects.NewQuestionStatistics(), status: types.QuestionStatusDraft, createdAt: now, updatedAt: now, createdBy: createdBy, version: 1, isActive: true, } return question, nil } // NewQuestionWithID creates a question with a specific ID (for loading from persistence) func NewQuestionWithID( id types.QuestionID, questionText string, correctAnswer string, difficulty types.DifficultyLevel, theme *valueobjects.Theme, createdBy types.UserID, createdAt types.Timestamp, ) (*Question, error) { if id.IsEmpty() { return nil, errors.ErrValidationFailed("id", "question ID cannot be empty") } question, err := NewQuestion(questionText, correctAnswer, difficulty, theme, createdBy) if err != nil { return nil, err } question.id = id question.createdAt = createdAt question.updatedAt = createdAt return question, nil } // Getters // ID returns the question's unique identifier func (q *Question) ID() types.QuestionID { return q.id } // QuestionText returns the question text func (q *Question) QuestionText() string { return q.questionText } // CorrectAnswer returns the correct answer func (q *Question) CorrectAnswer() string { return q.correctAnswer } // Hints returns all hints for this question func (q *Question) Hints() []string { return append([]string(nil), q.hints...) // Return copy to prevent mutation } // Difficulty returns the question difficulty level func (q *Question) Difficulty() types.DifficultyLevel { return q.difficulty } // Theme returns the question theme func (q *Question) Theme() *valueobjects.Theme { return q.theme } // Tags returns all tags associated with this question func (q *Question) Tags() []string { return append([]string(nil), q.tags...) // Return copy } // Explanation returns the question explanation func (q *Question) Explanation() string { return q.explanation } // SourceURL returns the source URL if set func (q *Question) SourceURL() *string { return q.sourceURL } // AlternativeAnswers returns all alternative answers func (q *Question) AlternativeAnswers() []string { return append([]string(nil), q.alternativeAnswers...) // Return copy } // Statistics returns usage statistics func (q *Question) Statistics() *valueobjects.QuestionStatistics { return q.statistics } // Status returns the question status func (q *Question) Status() types.QuestionStatus { return q.status } // CreatedAt returns the creation timestamp func (q *Question) CreatedAt() types.Timestamp { return q.createdAt } // UpdatedAt returns the last update timestamp func (q *Question) UpdatedAt() types.Timestamp { return q.updatedAt } // CreatedBy returns the creator user ID func (q *Question) CreatedBy() types.UserID { return q.createdBy } // Version returns the current version func (q *Question) Version() int { return q.version } // IsActive returns true if the question is active func (q *Question) IsActive() bool { return q.isActive && q.status != types.QuestionStatusArchived } // Business methods // UpdateQuestionText updates the question text func (q *Question) UpdateQuestionText(newText string) error { if err := validateQuestionText(newText); err != nil { return fmt.Errorf("invalid question text: %w", err) } if !q.CanBeModified() { return errors.ErrOperationNotAllowed("update question text", "question cannot be modified in current status") } q.questionText = strings.TrimSpace(newText) q.markUpdated() return nil } // UpdateCorrectAnswer updates the correct answer func (q *Question) UpdateCorrectAnswer(newAnswer string) error { if err := validateCorrectAnswer(newAnswer); err != nil { return fmt.Errorf("invalid correct answer: %w", err) } if !q.CanBeModified() { return errors.ErrOperationNotAllowed("update correct answer", "question cannot be modified in current status") } q.correctAnswer = strings.TrimSpace(newAnswer) q.markUpdated() return nil } // UpdateDifficulty updates the difficulty level func (q *Question) UpdateDifficulty(newDifficulty types.DifficultyLevel) error { if !q.CanBeModified() { return errors.ErrOperationNotAllowed("update difficulty", "question cannot be modified in current status") } q.difficulty = newDifficulty q.markUpdated() return nil } // UpdateTheme updates the question theme func (q *Question) UpdateTheme(newTheme *valueobjects.Theme) error { if newTheme == nil { return errors.ErrValidationFailed("theme", "theme cannot be nil") } if !q.CanBeModified() { return errors.ErrOperationNotAllowed("update theme", "question cannot be modified in current status") } q.theme = newTheme q.markUpdated() return nil } // AddHint adds a hint to the question func (q *Question) AddHint(hint string) error { hint = strings.TrimSpace(hint) if hint == "" { return errors.ErrValidationFailed("hint", "hint cannot be empty") } if len(hint) > types.MaxHintLength { return errors.ErrValidationFailed("hint", fmt.Sprintf("hint too long (max %d characters)", types.MaxHintLength)) } if len(q.hints) >= types.MaxHintsPerQuestion { return errors.ErrValidationFailed("hints", fmt.Sprintf("too many hints (max %d)", types.MaxHintsPerQuestion)) } // Check for duplicate hints for _, existingHint := range q.hints { if existingHint == hint { return errors.ErrDuplicateEntry("hint", hint) } } q.hints = append(q.hints, hint) q.markUpdated() return nil } // RemoveHint removes a hint by index func (q *Question) RemoveHint(index int) error { if index < 0 || index >= len(q.hints) { return errors.ErrValidationFailed("index", "hint index out of range") } if !q.CanBeModified() { return errors.ErrOperationNotAllowed("remove hint", "question cannot be modified in current status") } q.hints = append(q.hints[:index], q.hints[index+1:]...) q.markUpdated() return nil } // UpdateHint updates a hint at a specific index func (q *Question) UpdateHint(index int, newHint string) error { if index < 0 || index >= len(q.hints) { return errors.ErrValidationFailed("index", "hint index out of range") } newHint = strings.TrimSpace(newHint) if newHint == "" { return errors.ErrValidationFailed("hint", "hint cannot be empty") } if len(newHint) > types.MaxHintLength { return errors.ErrValidationFailed("hint", fmt.Sprintf("hint too long (max %d characters)", types.MaxHintLength)) } if !q.CanBeModified() { return errors.ErrOperationNotAllowed("update hint", "question cannot be modified in current status") } q.hints[index] = newHint q.markUpdated() return nil } // AddTag adds a tag to the question func (q *Question) AddTag(tag string) error { tag = strings.TrimSpace(strings.ToLower(tag)) if tag == "" { return errors.ErrValidationFailed("tag", "tag cannot be empty") } if len(tag) > types.MaxTagLength { return errors.ErrValidationFailed("tag", fmt.Sprintf("tag too long (max %d characters)", types.MaxTagLength)) } if len(q.tags) >= types.MaxTagsPerQuestion { return errors.ErrValidationFailed("tags", fmt.Sprintf("too many tags (max %d)", types.MaxTagsPerQuestion)) } // Check for duplicate tags for _, existingTag := range q.tags { if existingTag == tag { return errors.ErrDuplicateEntry("tag", tag) } } q.tags = append(q.tags, tag) q.markUpdated() return nil } // RemoveTag removes a tag from the question func (q *Question) RemoveTag(tag string) error { tag = strings.TrimSpace(strings.ToLower(tag)) for i, existingTag := range q.tags { if existingTag == tag { q.tags = append(q.tags[:i], q.tags[i+1:]...) q.markUpdated() return nil } } return errors.ErrNotFound("tag", tag) } // SetExplanation sets the explanation for the question func (q *Question) SetExplanation(explanation string) error { explanation = strings.TrimSpace(explanation) if len(explanation) > types.MaxExplanationLength { return errors.ErrValidationFailed("explanation", fmt.Sprintf("explanation too long (max %d characters)", types.MaxExplanationLength)) } q.explanation = explanation q.markUpdated() return nil } // SetSourceURL sets the source URL for the question func (q *Question) SetSourceURL(url string) error { url = strings.TrimSpace(url) if url == "" { q.sourceURL = nil } else { if len(url) > types.MaxURLLength { return errors.ErrValidationFailed("source_url", fmt.Sprintf("URL too long (max %d characters)", types.MaxURLLength)) } q.sourceURL = &url } q.markUpdated() return nil } // AddAlternativeAnswer adds an alternative correct answer func (q *Question) AddAlternativeAnswer(answer string) error { answer = strings.TrimSpace(answer) if answer == "" { return errors.ErrValidationFailed("alternative_answer", "alternative answer cannot be empty") } if len(answer) > types.MaxAnswerLength { return errors.ErrValidationFailed("alternative_answer", fmt.Sprintf("alternative answer too long (max %d characters)", types.MaxAnswerLength)) } if len(q.alternativeAnswers) >= types.MaxAlternativeAnswers { return errors.ErrValidationFailed("alternative_answers", fmt.Sprintf("too many alternative answers (max %d)", types.MaxAlternativeAnswers)) } // Check for duplicates (including the main correct answer) if answer == q.correctAnswer { return errors.ErrDuplicateEntry("alternative_answer", "alternative answer matches correct answer") } for _, existing := range q.alternativeAnswers { if existing == answer { return errors.ErrDuplicateEntry("alternative_answer", answer) } } q.alternativeAnswers = append(q.alternativeAnswers, answer) q.markUpdated() return nil } // RemoveAlternativeAnswer removes an alternative answer func (q *Question) RemoveAlternativeAnswer(answer string) error { answer = strings.TrimSpace(answer) for i, existing := range q.alternativeAnswers { if existing == answer { q.alternativeAnswers = append(q.alternativeAnswers[:i], q.alternativeAnswers[i+1:]...) q.markUpdated() return nil } } return errors.ErrNotFound("alternative_answer", answer) } // Publish transitions the question to published status func (q *Question) Publish() error { if q.status != types.QuestionStatusDraft && q.status != types.QuestionStatusReview { return errors.ErrOperationNotAllowed("publish", fmt.Sprintf("cannot publish question in %s status", q.status)) } // Validate question is ready for publishing if err := q.ValidateForPublishing(); err != nil { return fmt.Errorf("question not ready for publishing: %w", err) } q.status = types.QuestionStatusPublished q.markUpdated() return nil } // Archive transitions the question to archived status func (q *Question) Archive() error { if q.status == types.QuestionStatusArchived { return errors.ErrOperationNotAllowed("archive", "question is already archived") } q.status = types.QuestionStatusArchived q.isActive = false q.markUpdated() return nil } // SendForReview transitions the question to review status func (q *Question) SendForReview() error { if q.status != types.QuestionStatusDraft { return errors.ErrOperationNotAllowed("send for review", fmt.Sprintf("cannot send for review from %s status", q.status)) } q.status = types.QuestionStatusReview q.markUpdated() return nil } // Reject transitions the question back to draft status func (q *Question) Reject() error { if q.status != types.QuestionStatusReview { return errors.ErrOperationNotAllowed("reject", "can only reject questions under review") } q.status = types.QuestionStatusDraft q.markUpdated() return nil } // RecordUsage records usage statistics for this question func (q *Question) RecordUsage(wasCorrect bool, difficulty types.DifficultyLevel) error { return q.statistics.RecordUsage(wasCorrect, difficulty) } // Query methods // CanBeModified returns true if the question can be modified func (q *Question) CanBeModified() bool { return q.status == types.QuestionStatusDraft || q.status == types.QuestionStatusReview } // IsPublished returns true if the question is published func (q *Question) IsPublished() bool { return q.status == types.QuestionStatusPublished } // IsArchived returns true if the question is archived func (q *Question) IsArchived() bool { return q.status == types.QuestionStatusArchived } // HasTag returns true if the question has the specified tag func (q *Question) HasTag(tag string) bool { tag = strings.TrimSpace(strings.ToLower(tag)) for _, existingTag := range q.tags { if existingTag == tag { return true } } return false } // GetFirstHint returns the first hint if available func (q *Question) GetFirstHint() string { if len(q.hints) > 0 { return q.hints[0] } return "" } // HasHints returns true if the question has hints func (q *Question) HasHints() bool { return len(q.hints) > 0 } // GetAllAcceptableAnswers returns all acceptable answers (correct + alternatives) func (q *Question) GetAllAcceptableAnswers() []string { answers := make([]string, 0, 1+len(q.alternativeAnswers)) answers = append(answers, q.correctAnswer) answers = append(answers, q.alternativeAnswers...) return answers } // Validation methods // ValidateForPublishing validates the question is ready for publishing func (q *Question) ValidateForPublishing() error { result := q.Validate() if !result.IsValid() { return fmt.Errorf("validation failed: %s", strings.Join(result.Errors(), ", ")) } // Additional publishing requirements if len(q.hints) == 0 { return errors.ErrValidationFailed("hints", "published questions must have at least one hint") } if q.explanation == "" { return errors.ErrValidationFailed("explanation", "published questions must have an explanation") } return nil } // Validate performs comprehensive validation func (q *Question) Validate() *types.ValidationResult { result := types.NewValidationResult() // Validate ID if q.id.IsEmpty() { result.AddError("question ID cannot be empty") } // Validate question text if err := validateQuestionText(q.questionText); err != nil { result.AddErrorf("invalid question text: %v", err) } // Validate correct answer if err := validateCorrectAnswer(q.correctAnswer); err != nil { result.AddErrorf("invalid correct answer: %v", err) } // Validate theme if q.theme == nil { result.AddError("theme cannot be nil") } // Validate creator if q.createdBy.IsEmpty() { result.AddError("creator user ID cannot be empty") } // Validate hints if len(q.hints) > types.MaxHintsPerQuestion { result.AddErrorf("too many hints (max %d)", types.MaxHintsPerQuestion) } // Validate tags if len(q.tags) > types.MaxTagsPerQuestion { result.AddErrorf("too many tags (max %d)", types.MaxTagsPerQuestion) } // Validate alternative answers if len(q.alternativeAnswers) > types.MaxAlternativeAnswers { result.AddErrorf("too many alternative answers (max %d)", types.MaxAlternativeAnswers) } return result } // Helper methods // markUpdated updates the timestamp and version func (q *Question) markUpdated() { q.updatedAt = types.NewTimestamp() q.version++ } // GetAge returns how old the question is func (q *Question) GetAge() time.Duration { now := types.NewTimestamp() return now.Sub(q.createdAt) } // ToSnapshot creates a read-only snapshot of the question func (q *Question) ToSnapshot() *QuestionSnapshot { return &QuestionSnapshot{ ID: q.id, QuestionText: q.questionText, CorrectAnswer: q.correctAnswer, Hints: append([]string(nil), q.hints...), Difficulty: q.difficulty, Theme: q.theme.ToSnapshot(), Tags: append([]string(nil), q.tags...), Explanation: q.explanation, SourceURL: q.sourceURL, AlternativeAnswers: append([]string(nil), q.alternativeAnswers...), Statistics: q.statistics.ToSnapshot(), Status: q.status, CreatedAt: q.createdAt, UpdatedAt: q.updatedAt, CreatedBy: q.createdBy, Version: q.version, IsActive: q.isActive, } } // QuestionSnapshot represents a read-only view of a question type QuestionSnapshot struct { ID types.QuestionID `json:"id"` QuestionText string `json:"question_text"` CorrectAnswer string `json:"correct_answer"` Hints []string `json:"hints"` Difficulty types.DifficultyLevel `json:"difficulty"` Theme *valueobjects.ThemeSnapshot `json:"theme"` Tags []string `json:"tags"` Explanation string `json:"explanation"` SourceURL *string `json:"source_url,omitempty"` AlternativeAnswers []string `json:"alternative_answers"` Statistics *valueobjects.QuestionStatisticsSnapshot `json:"statistics"` Status types.QuestionStatus `json:"status"` CreatedAt types.Timestamp `json:"created_at"` UpdatedAt types.Timestamp `json:"updated_at"` CreatedBy types.UserID `json:"created_by"` Version int `json:"version"` IsActive bool `json:"is_active"` } // Validation helper functions func validateQuestionText(text string) error { text = strings.TrimSpace(text) if text == "" { return errors.ErrValidationFailed("question_text", "question text cannot be empty") } if len(text) < types.MinQuestionTextLength { return errors.ErrValidationFailed("question_text", fmt.Sprintf("question text too short (min %d characters)", types.MinQuestionTextLength)) } if len(text) > types.MaxQuestionTextLength { return errors.ErrValidationFailed("question_text", fmt.Sprintf("question text too long (max %d characters)", types.MaxQuestionTextLength)) } return nil } func validateCorrectAnswer(answer string) error { answer = strings.TrimSpace(answer) if answer == "" { return errors.ErrValidationFailed("correct_answer", "correct answer cannot be empty") } if len(answer) > types.MaxAnswerLength { return errors.ErrValidationFailed("correct_answer", fmt.Sprintf("correct answer too long (max %d characters)", types.MaxAnswerLength)) } return nil }