package question import ( "context" cryptorand "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "math/big" "sort" "strings" "time" domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" sharedsecurity "knowfoolery/backend/shared/infra/security" ) // AnswerValidationResult is the output for answer validation. type AnswerValidationResult struct { Matched bool Score float64 Threshold float64 } // Cache describes random question cache behavior. type Cache interface { Get(ctx context.Context, key string) (*domain.Question, bool) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) Invalidate(ctx context.Context) } // Service orchestrates question use-cases. type Service struct { repo domain.Repository cache Cache randomCacheTTL time.Duration randomMaxExclusions int } // NewService creates a new question service. func NewService(repo domain.Repository, cache Cache, randomCacheTTL time.Duration, randomMaxExclusions int) *Service { if randomCacheTTL <= 0 { randomCacheTTL = 5 * time.Minute } if randomMaxExclusions <= 0 { randomMaxExclusions = 200 } return &Service{ repo: repo, cache: cache, randomCacheTTL: randomCacheTTL, randomMaxExclusions: randomMaxExclusions, } } // GetRandomQuestion returns a random active question based on optional filters. func (s *Service) GetRandomQuestion(ctx context.Context, req RandomQuestionRequest) (*domain.Question, error) { exclusions := normalizeExclusions(req.ExcludeQuestionIDs, s.randomMaxExclusions) filter := domain.RandomFilter{ ExcludeQuestionIDs: exclusions, Theme: sharedsecurity.SanitizeTheme(req.Theme), Difficulty: req.Difficulty, } if filter.Difficulty != "" && !domain.IsValidDifficulty(filter.Difficulty) { return nil, domain.ErrValidationFailed } cacheKey := randomQuestionCacheKey(filter) if s.cache != nil { if cached, ok := s.cache.Get(ctx, cacheKey); ok { return cached, nil } } count, err := s.repo.CountRandomCandidates(ctx, filter) if err != nil { return nil, err } if count == 0 { return nil, domain.ErrNoQuestionsAvailable } offset := 0 if count > 1 { n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(count))) if err == nil { offset = int(n.Int64()) } } q, err := s.repo.RandomByOffset(ctx, filter, offset) if err != nil { return nil, err } if s.cache != nil { s.cache.Set(ctx, cacheKey, q, s.randomCacheTTL) } return q, nil } // GetQuestionByID returns a question by identifier. func (s *Service) GetQuestionByID(ctx context.Context, id string) (*domain.Question, error) { q, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } return q, nil } // ValidateAnswerByQuestionID validates a provided answer against a stored question answer. func (s *Service) ValidateAnswerByQuestionID(ctx context.Context, id string, provided string) (*AnswerValidationResult, error) { provided = sharedsecurity.SanitizeAnswer(provided) if provided == "" { return nil, domain.ErrValidationFailed } q, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } matched, score := domain.IsAnswerMatch(q.Answer, provided) return &AnswerValidationResult{ Matched: matched, Score: score, Threshold: 0.85, }, nil } // CreateQuestion creates a new question. func (s *Service) CreateQuestion(ctx context.Context, in CreateQuestionInput) (*domain.Question, error) { q, err := s.toQuestion(in.Theme, in.Text, in.Answer, in.Hint, in.Difficulty) if err != nil { return nil, err } created, err := s.repo.Create(ctx, q) if err != nil { return nil, err } s.invalidateRandomCache(ctx) return created, nil } // UpdateQuestion updates an existing question. func (s *Service) UpdateQuestion(ctx context.Context, id string, in UpdateQuestionInput) (*domain.Question, error) { q, err := s.toQuestion(in.Theme, in.Text, in.Answer, in.Hint, in.Difficulty) if err != nil { return nil, err } q.IsActive = in.IsActive updated, err := s.repo.Update(ctx, id, q) if err != nil { return nil, err } s.invalidateRandomCache(ctx) return updated, nil } // DeleteQuestion soft-deletes a question. func (s *Service) DeleteQuestion(ctx context.Context, id string) error { if err := s.repo.SoftDelete(ctx, id); err != nil { return err } s.invalidateRandomCache(ctx) return nil } // ListThemes returns all active themes. func (s *Service) ListThemes(ctx context.Context) ([]string, error) { return s.repo.ListThemes(ctx) } // BulkImport imports questions in batch with partial failure reporting. func (s *Service) BulkImport(ctx context.Context, items []BulkImportItem, maxItems int) (*BulkImportResult, error) { if maxItems > 0 && len(items) > maxItems { return nil, domain.ErrValidationFailed } questions := make([]*domain.Question, 0, len(items)) errList := make([]domain.BulkError, 0) for i, item := range items { sanitized, err := sanitizeAndValidateItem(item) if err != nil { errList = append(errList, domain.BulkError{Index: i, Reason: err.Error()}) continue } q, err := s.toQuestion(sanitized.Theme, sanitized.Text, sanitized.Answer, sanitized.Hint, sanitized.Difficulty) if err != nil { errList = append(errList, domain.BulkError{Index: i, Reason: err.Error()}) continue } questions = append(questions, q) } createdCount := 0 if len(questions) > 0 { created, bulkErrs, err := s.repo.BulkCreate(ctx, questions) if err != nil { return nil, err } createdCount = created errList = append(errList, bulkErrs...) } s.invalidateRandomCache(ctx) return &BulkImportResult{ CreatedCount: createdCount, FailedCount: len(items) - createdCount, Errors: errList, }, nil } func (s *Service) toQuestion(theme, text, answer, hint string, difficulty domain.Difficulty) (*domain.Question, error) { theme = sharedsecurity.SanitizeTheme(theme) text = sharedsecurity.SanitizeQuestionText(text) answer = sharedsecurity.SanitizeAnswer(answer) hint = sharedsecurity.SanitizeQuestionText(hint) if !domain.IsValidDifficulty(difficulty) { return nil, domain.ErrValidationFailed } if theme == "" || text == "" || answer == "" { return nil, domain.ErrValidationFailed } return &domain.Question{ Theme: theme, Text: text, Answer: answer, Hint: hint, Difficulty: difficulty, IsActive: true, }, nil } func (s *Service) invalidateRandomCache(ctx context.Context) { if s.cache != nil { s.cache.Invalidate(ctx) } } func normalizeExclusions(in []string, max int) []string { if len(in) == 0 { return nil } unique := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) for _, id := range in { id = strings.TrimSpace(id) if id == "" { continue } if _, exists := unique[id]; exists { continue } unique[id] = struct{}{} out = append(out, id) if len(out) >= max { break } } sort.Strings(out) return out } func randomQuestionCacheKey(filter domain.RandomFilter) string { payload, _ := json.Marshal(map[string]interface{}{ "theme": strings.ToLower(strings.TrimSpace(filter.Theme)), "difficulty": filter.Difficulty, "exclude_ids": filter.ExcludeQuestionIDs, }) h := sha256.Sum256(payload) return fmt.Sprintf("qb:random:v1:%s", hex.EncodeToString(h[:])) }