DeutschLernen/GermanApp/Application/Services/QuizQuestionService.cs
Lasse Rune Hansen 9215ed7a05 feat(backend/application): Complete Lesson Management feature implementation
- 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>
2026-06-13 09:34:42 +02:00

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);
}
}