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