- Integrated Quiz completion with ProgressService: when a quiz is passed (>=80%), the associated lesson is automatically marked as completed - Created LessonUnlockService for centralized lesson unlocking business logic - Created LevelCompletionCalculator for calculating level completion metrics - Registered new services in Program.cs DI container - Updated QuizQuestionService to include ProgressService dependency - Updated documentation (ROADMAP.md, lesson-management.md) to reflect completion - Fixed QuizQuestionServiceTests to work with updated dependencies All tests pass (296 total: 148 unit + 148 integration). Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
506 lines
21 KiB
C#
506 lines
21 KiB
C#
using GermanApp.Application.DTOs;
|
|
using GermanApp.Domain.Entities;
|
|
using GermanApp.Domain.Exceptions;
|
|
using GermanApp.Domain.Interfaces;
|
|
|
|
namespace GermanApp.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Application service for managing quiz questions.
|
|
/// This is part of the Application layer.
|
|
/// </summary>
|
|
public class QuizQuestionService
|
|
{
|
|
private readonly IQuizQuestionRepository _quizQuestionRepository;
|
|
private readonly IQuizOptionRepository _quizOptionRepository;
|
|
private readonly IQuizRepository _quizRepository;
|
|
private readonly ILessonRepository _lessonRepository;
|
|
private readonly ProgressService _progressService;
|
|
|
|
public QuizQuestionService(
|
|
IQuizQuestionRepository quizQuestionRepository,
|
|
IQuizOptionRepository quizOptionRepository,
|
|
IQuizRepository quizRepository,
|
|
ILessonRepository lessonRepository,
|
|
ProgressService progressService)
|
|
{
|
|
_quizQuestionRepository = quizQuestionRepository;
|
|
_quizOptionRepository = quizOptionRepository;
|
|
_quizRepository = quizRepository;
|
|
_lessonRepository = lessonRepository;
|
|
_progressService = progressService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all quiz questions.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetAllOrderedAsync(cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a quiz question by its ID.
|
|
/// </summary>
|
|
public virtual async Task<QuizQuestionDto?> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
return question?.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by lesson ID.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets active quiz questions by lesson ID.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a random set of quiz questions for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetRandomQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by difficulty level.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByDifficultyAsync(difficulty, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by type.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByTypeAsync(type, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new quiz question with its options.
|
|
/// </summary>
|
|
public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
// Validate that the quiz exists
|
|
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
|
|
if (quiz == null)
|
|
throw new NotFoundException("Quiz does not exist");
|
|
|
|
// Validate question order uniqueness within the quiz
|
|
await ValidateQuestionOrderUniquenessAsync(0, dto.QuizId, dto.Order, cancellationToken);
|
|
|
|
// Create the question
|
|
var question = dto.ToEntity();
|
|
var createdQuestion = await _quizQuestionRepository.AddAsync(question, cancellationToken);
|
|
|
|
// Create options if provided
|
|
if (dto.Options != null && dto.Options.Any())
|
|
{
|
|
foreach (var optionDto in dto.Options)
|
|
{
|
|
var option = optionDto.ToEntity(createdQuestion.Id);
|
|
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
}
|
|
}
|
|
|
|
// Reload the question with options
|
|
var questionWithOptions = await _quizQuestionRepository.GetByIdAsync(createdQuestion.Id, cancellationToken);
|
|
return questionWithOptions?.ToDto() ?? createdQuestion.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing quiz question and its options.
|
|
/// </summary>
|
|
public virtual async Task<QuizQuestionDto?> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (question == null)
|
|
return null;
|
|
|
|
// Validate that the quiz exists
|
|
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
|
|
if (quiz == null)
|
|
throw new NotFoundException("Quiz does not exist");
|
|
|
|
// Validate question order uniqueness within the quiz
|
|
await ValidateQuestionOrderUniquenessAsync(id, dto.QuizId, dto.Order, cancellationToken);
|
|
|
|
// Update the question
|
|
question.UpdateFromDto(dto);
|
|
await _quizQuestionRepository.UpdateAsync(question, cancellationToken);
|
|
|
|
// Update options if provided
|
|
if (dto.Options != null)
|
|
{
|
|
// Delete existing options
|
|
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
|
|
|
// Add new options
|
|
foreach (var optionDto in dto.Options)
|
|
{
|
|
var option = new CreateQuizOptionDto(optionDto.Text, optionDto.IsCorrect, optionDto.Order).ToEntity(id);
|
|
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
}
|
|
}
|
|
|
|
// Reload the question with updated options
|
|
var updatedQuestion = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
return updatedQuestion?.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that the question order is unique within its quiz.
|
|
/// </summary>
|
|
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int quizId, int order, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await _quizQuestionRepository.OrderExistsInQuizAsync(quizId, order, excludeQuestionId, cancellationToken);
|
|
if (exists)
|
|
throw new ValidationException("A quiz question with this order already exists in the specified quiz");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a quiz question and its options by its ID.
|
|
/// </summary>
|
|
public virtual async Task<bool> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (question == null)
|
|
return false;
|
|
|
|
// Delete all options for this question
|
|
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
|
|
|
// Delete the question
|
|
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz options for a specific question.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
{
|
|
var options = await _quizOptionRepository.GetByQuestionAsync(questionId, cancellationToken);
|
|
return options.Select(o => o.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the correct option for a question.
|
|
/// </summary>
|
|
public virtual async Task<QuizOptionDto?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
{
|
|
var option = await _quizOptionRepository.GetCorrectOptionAsync(questionId, cancellationToken);
|
|
return option?.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all correct options for a question (for MultipleSelect type).
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
|
{
|
|
var options = await _quizOptionRepository.GetCorrectOptionsAsync(questionId, cancellationToken);
|
|
return options.Select(o => o.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a quiz option for a question.
|
|
/// </summary>
|
|
public virtual async Task<QuizOptionDto> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
// Validate that the question exists
|
|
var question = await _quizQuestionRepository.GetByIdAsync(questionId, cancellationToken);
|
|
if (question == null)
|
|
throw new ArgumentException("Quiz question does not exist");
|
|
|
|
// Validate option order uniqueness within the question
|
|
await ValidateOptionOrderUniquenessAsync(questionId, dto.Order, null, cancellationToken);
|
|
|
|
var option = dto.ToEntity(questionId);
|
|
var createdOption = await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
return createdOption.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing quiz option.
|
|
/// </summary>
|
|
public virtual async Task<QuizOptionDto?> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (option == null)
|
|
return null;
|
|
|
|
// Validate that the question exists
|
|
var question = await _quizQuestionRepository.GetByIdAsync(dto.Id, cancellationToken);
|
|
if (question == null)
|
|
throw new ArgumentException("Quiz question does not exist");
|
|
|
|
// Validate option order uniqueness within the question
|
|
await ValidateOptionOrderUniquenessAsync(option.QuizQuestionId, dto.Order, id, cancellationToken);
|
|
|
|
option.UpdateFromDto(dto);
|
|
await _quizOptionRepository.UpdateAsync(option, cancellationToken);
|
|
return option.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that the option order is unique within its question.
|
|
/// </summary>
|
|
private async Task ValidateOptionOrderUniquenessAsync(int questionId, int order, int? excludeOptionId, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await _quizOptionRepository.OrderExistsForQuestionAsync(questionId, order, excludeOptionId, cancellationToken);
|
|
if (exists)
|
|
throw new ArgumentException("An option with this order already exists for the specified question");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a quiz option by its ID.
|
|
/// </summary>
|
|
public virtual async Task<bool> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (option == null)
|
|
return false;
|
|
|
|
await _quizOptionRepository.DeleteAsync(option, cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Submits quiz answers for a specific quiz and returns the result.
|
|
/// </summary>
|
|
public virtual async Task<QuizResultDto> SubmitQuizAnswersForQuizAsync(int userId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
var quiz = await _quizRepository.GetWithQuestionsAsync(dto.QuizId, cancellationToken);
|
|
if (quiz == null)
|
|
throw new NotFoundException("Quiz does not exist");
|
|
|
|
var questions = quiz.Questions?.Where(q => q.IsActive).OrderBy(q => q.Order).ToList() ?? new List<QuizQuestion>();
|
|
var totalQuestions = questions.Count;
|
|
var totalPoints = questions.Sum(q => q.Points);
|
|
var scorePoints = 0;
|
|
var questionResults = new List<QuizQuestionResultDto>();
|
|
|
|
// Get user's answers (using QuizAnswerDto which has the same fields as SubmitAnswerDto)
|
|
var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId);
|
|
|
|
foreach (var question in questions)
|
|
{
|
|
var isCorrect = false;
|
|
var userAnswerText = string.Empty;
|
|
var correctAnswer = question.CorrectAnswer ?? string.Empty;
|
|
|
|
if (userAnswers.TryGetValue(question.Id, out var userAnswer))
|
|
{
|
|
userAnswerText = userAnswer.AnswerText ?? string.Empty;
|
|
|
|
switch (question.Type)
|
|
{
|
|
case QuestionType.MultipleChoice:
|
|
// Check if the selected option is correct
|
|
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length == 1)
|
|
{
|
|
var selectedOptionId = userAnswer.SelectedOptionIds[0];
|
|
var isOptionCorrect = await _quizOptionRepository.GetByIdAsync(selectedOptionId, cancellationToken);
|
|
isCorrect = isOptionCorrect?.IsCorrect == true;
|
|
}
|
|
break;
|
|
|
|
case QuestionType.MultipleSelect:
|
|
// Check if all selected options are correct and no incorrect ones are selected
|
|
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length > 0)
|
|
{
|
|
var correctOptions = await _quizOptionRepository.GetCorrectOptionsAsync(question.Id, cancellationToken);
|
|
var selectedOptions = await _quizOptionRepository.GetByQuestionAsync(question.Id, cancellationToken);
|
|
|
|
var correctIds = new HashSet<int>(correctOptions.Select(o => o.Id));
|
|
var selectedIds = new HashSet<int>(userAnswer.SelectedOptionIds);
|
|
|
|
// All selected must be correct, and all correct must be selected
|
|
isCorrect = selectedIds.SetEquals(correctIds);
|
|
}
|
|
break;
|
|
|
|
case QuestionType.TrueFalse:
|
|
// Check if the answer matches
|
|
if (userAnswer.AnswerText != null)
|
|
{
|
|
isCorrect = userAnswer.AnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
break;
|
|
|
|
case QuestionType.FillInTheBlank:
|
|
case QuestionType.ShortAnswer:
|
|
// Case-insensitive comparison with trimming
|
|
if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer))
|
|
{
|
|
isCorrect = userAnswerText.Trim().Equals(correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
break;
|
|
|
|
case QuestionType.Matching:
|
|
// For matching, we would need more complex logic
|
|
// This is a simplified implementation
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (isCorrect)
|
|
scorePoints += question.Points;
|
|
|
|
questionResults.Add(new QuizQuestionResultDto(
|
|
question.Id,
|
|
question.QuestionText,
|
|
question.Type,
|
|
question.Points,
|
|
isCorrect,
|
|
correctAnswer,
|
|
userAnswerText));
|
|
}
|
|
|
|
var scorePercentage = totalPoints > 0 ? (double)scorePoints / totalPoints * 100 : 0;
|
|
var passed = quiz.IsPassed(scorePercentage);
|
|
|
|
// Update user progress if quiz is passed
|
|
if (passed && userId > 0)
|
|
{
|
|
try
|
|
{
|
|
await _progressService.MarkLessonAsCompletedAsync(
|
|
userId,
|
|
quiz.LessonId,
|
|
(int)scorePercentage,
|
|
cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
// Log error but don't fail the quiz submission
|
|
// In production, you would use a logger here
|
|
// For now, we'll just swallow the exception to keep the quiz submission working
|
|
// Consider adding logging: _logger.LogError(ex, "Failed to update progress for user {UserId} lesson {LessonId}", userId, quiz.LessonId);
|
|
}
|
|
}
|
|
|
|
return new QuizResultDto(
|
|
quiz.Id,
|
|
quiz.LessonId,
|
|
quiz.Title,
|
|
totalQuestions,
|
|
totalPoints,
|
|
scorePoints,
|
|
scorePercentage,
|
|
passed,
|
|
TimeSpan.Zero, // Would be calculated based on actual time taken
|
|
questionResults);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Submits quiz answers and returns the result (legacy - converts SubmitQuizAnswersDto to SubmitQuizDto).
|
|
/// </summary>
|
|
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
// Convert legacy DTO to new format
|
|
// SubmitQuizAnswersDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
|
|
// SubmitQuizDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
|
|
// They're the same, so just create a new SubmitQuizDto
|
|
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
|
|
return await SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the number of quiz questions for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<int> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
return questions.Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the difficulty distribution for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<IDictionary<int, int>> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
var distribution = new Dictionary<int, int>();
|
|
|
|
for (int i = 1; i <= 5; i++)
|
|
{
|
|
distribution[i] = questions.Count(q => q.Difficulty == i);
|
|
}
|
|
|
|
return distribution;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the type distribution for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<IDictionary<string, int>> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
var distribution = new Dictionary<string, int>();
|
|
|
|
foreach (var type in Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>())
|
|
{
|
|
distribution[type.ToString()] = questions.Count(q => q.Type == type);
|
|
}
|
|
|
|
return distribution;
|
|
}
|
|
|
|
// ============================================
|
|
// Quiz-based methods (QuizId)
|
|
// ============================================
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by quiz ID.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByQuizAsync(quizId, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets active quiz questions by quiz ID.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByQuizAsync(quizId, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a random set of quiz questions for a quiz.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetRandomQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the total points for a quiz.
|
|
/// </summary>
|
|
public virtual async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _quizQuestionRepository.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
|
}
|
|
}
|