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.

729 lines
20 KiB
Go

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
}