DeutschLernen/GermanApp/Application/Services/QuizQuestionService.cs
Lasse Rune Hansen 04ad7ef008 feat(backend/domain): add quiz question feature with Docker migration support
- Add QuizQuestion and QuizOption domain entities with QuestionType enum
- Add IQuizQuestionRepository and IQuizOptionRepository interfaces
- Add QuizQuestionRepository and QuizOptionRepository EF Core implementations
- Add QuizQuestionService with CRUD, quiz submission, and statistics
- Add QuizQuestionsController with comprehensive REST API endpoints
- Add QuizQuestionDto and related DTOs for API communication
- Add EF Core migration (20260612050652_AddQuizQuestionTables) for QuizQuestion and QuizOption
- Add seed data with sample quiz questions for Greetings, Numbers, Grammar lessons
- Update AppDbContext with DbSets and entity configurations
- Update Program.cs with migration retry logic for Docker
- Update docker-compose.yml with SeedDatabase configuration
- Add unit tests for QuizQuestionService

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-12 16:49:27 +02:00

420 lines
18 KiB
C#

using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
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 ILessonRepository _lessonRepository;
public QuizQuestionService(
IQuizQuestionRepository quizQuestionRepository,
IQuizOptionRepository quizOptionRepository,
ILessonRepository lessonRepository)
{
_quizQuestionRepository = quizQuestionRepository;
_quizOptionRepository = quizOptionRepository;
_lessonRepository = lessonRepository;
}
/// <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 lesson exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
if (lesson == null)
throw new ArgumentException("Lesson does not exist");
// Validate question order uniqueness within the lesson
await ValidateQuestionOrderUniquenessAsync(0, dto.LessonId, 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 lesson exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
if (lesson == null)
throw new ArgumentException("Lesson does not exist");
// Validate question order uniqueness within the lesson
await ValidateQuestionOrderUniquenessAsync(id, dto.LessonId, 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 lesson.
/// </summary>
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int lessonId, int order, CancellationToken cancellationToken)
{
var exists = await _quizQuestionRepository.OrderExistsInLessonAsync(lessonId, order, excludeQuestionId, cancellationToken);
if (exists)
throw new ArgumentException("A quiz question with this order already exists in the specified lesson");
}
/// <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 and returns the result.
/// </summary>
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionRepository.GetByLessonAsync(dto.LessonId, cancellationToken);
var questionDictionary = questions.ToDictionary(q => q.Id);
var totalQuestions = questions.Count;
var correctAnswers = 0;
var questionResults = new List<QuizQuestionResultDto>();
// Get user's answers
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.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
}
break;
case QuestionType.Matching:
// For matching, we would need more complex logic
// This is a simplified implementation
break;
}
}
if (isCorrect)
correctAnswers++;
questionResults.Add(new QuizQuestionResultDto(
question.Id,
question.QuestionText,
question.Type,
isCorrect,
correctAnswer,
userAnswerText));
}
var scorePercentage = totalQuestions > 0 ? (double)correctAnswers / totalQuestions * 100 : 0;
var passed = scorePercentage >= 80; // Passing score is 80%
return new QuizResultDto(
dto.LessonId,
totalQuestions,
correctAnswers,
scorePercentage,
passed,
TimeSpan.Zero, // Would be calculated based on actual time taken
questionResults);
}
/// <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;
}
}