247 lines
7.5 KiB
Go
247 lines
7.5 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,
|
|
CorrectAnswers: score,
|
|
TotalQuestions: len(quiz.Questions),
|
|
}
|
|
|
|
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
|
|
}
|