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 }