sno-quiz/backend/internal/service/quiz_service.go
2025-09-17 22:22:14 +03:00

245 lines
7.4 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"sno/internal/models"
"sno/internal/repository"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/exp/slices"
)
// QuizService defines the interface for quiz-related business logic.
type QuizService interface {
ListActiveQuizzes(ctx context.Context) ([]models.Quiz, error)
CreateQuiz(ctx context.Context, quiz *models.Quiz) (*models.Quiz, error)
GetQuizByID(ctx context.Context, id int) (*models.Quiz, error)
UpdateQuiz(ctx context.Context, id int, quiz *models.Quiz) (*models.Quiz, error)
DeleteQuiz(ctx context.Context, id int) error
SubmitQuiz(ctx context.Context, userID int64, quizID int, submission models.SubmissionRequest) (*models.QuizAttempt, error)
CanUserRepeatQuiz(ctx context.Context, userID int64, quizID int) (*models.CanRepeatResponse, error)
}
// quizService implements the QuizService interface.
type quizService struct {
db *pgxpool.Pool // For transactions
quizRepo repository.QuizRepository
questionRepo repository.QuestionRepository
userRepo repository.UserRepository
userService UserService
quizAttemptRepo repository.QuizAttemptRepository
}
// NewQuizService creates a new instance of a quiz service.
func NewQuizService(db *pgxpool.Pool, qr repository.QuizRepository, questionr repository.QuestionRepository, ur repository.UserRepository, us UserService, qar repository.QuizAttemptRepository) QuizService {
return &quizService{
db: db,
quizRepo: qr,
questionRepo: questionr,
userRepo: ur,
userService: us,
quizAttemptRepo: qar,
}
}
// GetQuizByID retrieves a quiz and all its associated questions.
func (s *quizService) GetQuizByID(ctx context.Context, id int) (*models.Quiz, error) {
quiz, err := s.quizRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
questions, err := s.questionRepo.GetByQuizID(ctx, id)
if err != nil {
return nil, err
}
quiz.Questions = questions
return quiz, nil
}
// SubmitQuiz validates answers, calculates score, and awards stars.
func (s *quizService) SubmitQuiz(ctx context.Context, userID int64, quizID int, submission models.SubmissionRequest) (*models.QuizAttempt, error) {
quiz, err := s.GetQuizByID(ctx, quizID)
if err != nil {
return nil, fmt.Errorf("failed to get quiz for submission: %w", err)
}
// Ensure user exists before proceeding
_, err = s.userService.GetUserProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get user profile: %w", err)
}
if len(quiz.Questions) == 0 {
return nil, errors.New("cannot submit to a quiz with no questions")
}
// --- Submission Cooldown/Uniqueness Validation ---
lastAttempt, err := s.quizAttemptRepo.GetLatestByUserIDAndQuizID(ctx, userID, quizID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("failed to check previous attempts: %w", err)
}
if lastAttempt != nil {
if !quiz.CanRepeat {
return nil, errors.New("quiz has already been completed")
}
if quiz.RepeatCooldownHours != nil {
cooldown := time.Duration(*quiz.RepeatCooldownHours) * time.Hour
if time.Since(lastAttempt.CompletedAt) < cooldown {
return nil, fmt.Errorf("quiz is on cooldown, please try again later")
}
}
}
// --- Scoring Logic ---
score := 0
correctAnswers := make(map[int][]int) // questionID -> sorted list of correct optionIDs
for _, q := range quiz.Questions {
for _, o := range q.Options {
if o.IsCorrect {
correctAnswers[q.ID] = append(correctAnswers[q.ID], o.ID)
}
}
slices.Sort(correctAnswers[q.ID])
}
for _, userAnswer := range submission.Answers {
correct, exists := correctAnswers[userAnswer.QuestionID]
if !exists {
continue // User answered a question not in the quiz, ignore it.
}
slices.Sort(userAnswer.OptionIDs)
if slices.Equal(correct, userAnswer.OptionIDs) {
score++
}
}
starsEarned := 0
if len(quiz.Questions) > 0 {
starsEarned = int(float64(score) / float64(len(quiz.Questions)) * float64(quiz.RewardStars))
}
// --- Database Transaction ---
attempt := &models.QuizAttempt{
UserID: userID,
QuizID: quizID,
Score: score,
StarsEarned: starsEarned,
Answers: submission.Answers,
}
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx) // Rollback is a no-op if tx is committed
// Create attempt record
if _, err := s.quizAttemptRepo.Create(context.WithValue(ctx, "tx", tx), attempt); err != nil {
return nil, fmt.Errorf("failed to create quiz attempt: %w", err)
}
// Update user balance
if starsEarned > 0 {
if err := s.userRepo.UpdateStarsBalance(context.WithValue(ctx, "tx", tx), userID, starsEarned); err != nil {
return nil, fmt.Errorf("failed to update user balance: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return attempt, nil
}
// ListActiveQuizzes and CreateQuiz remain the same for now
func (s *quizService) ListActiveQuizzes(ctx context.Context) ([]models.Quiz, error) {
return s.quizRepo.GetAllActive(ctx)
}
func (s *quizService) CreateQuiz(ctx context.Context, quiz *models.Quiz) (*models.Quiz, error) {
return s.quizRepo.Create(ctx, quiz)
}
// CanUserRepeatQuiz checks if a user is allowed to repeat a specific quiz.
func (s *quizService) CanUserRepeatQuiz(ctx context.Context, userID int64, quizID int) (*models.CanRepeatResponse, error) {
quiz, err := s.quizRepo.GetByID(ctx, quizID)
if err != nil {
return nil, fmt.Errorf("failed to get quiz: %w", err)
}
lastAttempt, err := s.quizAttemptRepo.GetLatestByUserIDAndQuizID(ctx, userID, quizID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("failed to check previous attempts: %w", err)
}
// No previous attempt, so user can take the quiz
if lastAttempt == nil {
return &models.CanRepeatResponse{CanRepeat: true}, nil
}
// Quiz is marked as non-repeatable
if !quiz.CanRepeat {
return &models.CanRepeatResponse{CanRepeat: false}, nil
}
// Quiz is repeatable, check cooldown
if quiz.RepeatCooldownHours != nil {
cooldown := time.Duration(*quiz.RepeatCooldownHours) * time.Hour
nextAvailableAt := lastAttempt.CompletedAt.Add(cooldown)
if time.Now().Before(nextAvailableAt) {
return &models.CanRepeatResponse{
CanRepeat: false,
NextAvailableAt: &nextAvailableAt,
}, nil
}
}
// Cooldown has passed or doesn't exist
return &models.CanRepeatResponse{CanRepeat: true}, nil
}
// UpdateQuiz updates an existing quiz
func (s *quizService) UpdateQuiz(ctx context.Context, id int, quiz *models.Quiz) (*models.Quiz, error) {
// Check if quiz exists
existingQuiz, err := s.quizRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get existing quiz: %w", err)
}
if existingQuiz == nil {
return nil, errors.New("quiz not found")
}
// Update quiz
updatedQuiz, err := s.quizRepo.Update(ctx, id, quiz)
if err != nil {
return nil, fmt.Errorf("failed to update quiz: %w", err)
}
return updatedQuiz, nil
}
// DeleteQuiz deletes a quiz
func (s *quizService) DeleteQuiz(ctx context.Context, id int) error {
// Check if quiz exists
existingQuiz, err := s.quizRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("failed to get existing quiz: %w", err)
}
if existingQuiz == nil {
return errors.New("quiz not found")
}
// Delete quiz and associated questions
err = s.quizRepo.Delete(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete quiz: %w", err)
}
return nil
}