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>
This commit is contained in:
Lasse Rune Hansen 2026-06-12 16:49:27 +02:00
parent c8c5f7431d
commit 04ad7ef008
15 changed files with 2538 additions and 2 deletions

View file

@ -0,0 +1,191 @@
using GermanApp.Domain.Entities;
namespace GermanApp.Application.DTOs;
/// <summary>
/// Data Transfer Object for QuizQuestion - used for API responses.
/// This is a read-only representation of a QuizQuestion entity.
/// </summary>
public record QuizQuestionDto(
int Id,
int LessonId,
string LessonTitle,
string QuestionText,
QuestionType Type,
string? CorrectAnswer,
int Difficulty,
int Order,
bool IsActive,
DateTime CreatedAt,
DateTime? UpdatedAt,
IReadOnlyList<QuizOptionDto> Options);
/// <summary>
/// Data Transfer Object for creating a new QuizQuestion.
/// </summary>
public record CreateQuizQuestionDto(
int LessonId,
string QuestionText,
QuestionType Type,
string? CorrectAnswer,
int Difficulty,
int Order,
IReadOnlyList<CreateQuizOptionDto>? Options);
/// <summary>
/// Data Transfer Object for updating an existing QuizQuestion.
/// </summary>
public record UpdateQuizQuestionDto(
int LessonId,
string QuestionText,
QuestionType Type,
string? CorrectAnswer,
int Difficulty,
int Order,
bool IsActive,
IReadOnlyList<UpdateQuizOptionDto>? Options);
/// <summary>
/// Data Transfer Object for QuizOption - used for API responses.
/// </summary>
public record QuizOptionDto(
int Id,
int QuizQuestionId,
string Text,
bool IsCorrect,
int Order);
/// <summary>
/// Data Transfer Object for creating a new QuizOption.
/// </summary>
public record CreateQuizOptionDto(
string Text,
bool IsCorrect,
int Order);
/// <summary>
/// Data Transfer Object for updating an existing QuizOption.
/// </summary>
public record UpdateQuizOptionDto(
int Id,
string Text,
bool IsCorrect,
int Order);
/// <summary>
/// Data Transfer Object for quiz question with user's answer.
/// </summary>
public record QuizQuestionWithAnswerDto(
int QuizQuestionId,
int LessonId,
string QuestionText,
QuestionType Type,
IReadOnlyList<QuizOptionDto> Options,
string? UserAnswer,
bool IsCorrect);
/// <summary>
/// Data Transfer Object for submitting quiz answers.
/// </summary>
public record SubmitQuizAnswersDto(
int LessonId,
IReadOnlyList<QuizAnswerDto> Answers);
/// <summary>
/// Data Transfer Object for a single quiz answer submission.
/// </summary>
public record QuizAnswerDto(
int QuizQuestionId,
string? AnswerText,
int[]? SelectedOptionIds);
/// <summary>
/// Data Transfer Object for quiz result.
/// </summary>
public record QuizResultDto(
int LessonId,
int TotalQuestions,
int CorrectAnswers,
double ScorePercentage,
bool Passed,
TimeSpan TimeTaken,
IReadOnlyList<QuizQuestionResultDto> QuestionResults);
/// <summary>
/// Data Transfer Object for individual question result.
/// </summary>
public record QuizQuestionResultDto(
int QuizQuestionId,
string QuestionText,
QuestionType Type,
bool IsCorrect,
string? CorrectAnswer,
string? UserAnswer);
/// <summary>
/// Extension methods for mapping between QuizQuestion entity and DTOs.
/// </summary>
public static class QuizQuestionDtoExtensions
{
public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new(
quizQuestion.Id,
quizQuestion.LessonId,
quizQuestion.Lesson?.Title ?? "Unknown",
quizQuestion.QuestionText,
quizQuestion.Type,
quizQuestion.CorrectAnswer,
quizQuestion.Difficulty,
quizQuestion.Order,
quizQuestion.IsActive,
quizQuestion.CreatedAt,
quizQuestion.UpdatedAt,
quizQuestion.Options?.OrderBy(o => o.Order).Select(o => o.ToDto()).ToList() ?? new List<QuizOptionDto>());
public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) =>
QuizQuestion.Create(
dto.LessonId,
dto.QuestionText,
dto.Type,
dto.CorrectAnswer ?? string.Empty,
dto.Difficulty,
dto.Order);
public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto)
{
quizQuestion.UpdateLesson(dto.LessonId);
quizQuestion.UpdateQuestionText(dto.QuestionText);
quizQuestion.UpdateType(dto.Type);
quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty);
quizQuestion.UpdateDifficulty(dto.Difficulty);
quizQuestion.UpdateOrder(dto.Order);
if (dto.IsActive != quizQuestion.IsActive)
{
if (dto.IsActive)
quizQuestion.Activate();
else
quizQuestion.Deactivate();
}
}
public static QuizOptionDto ToDto(this QuizOption quizOption) => new(
quizOption.Id,
quizOption.QuizQuestionId,
quizOption.Text,
quizOption.IsCorrect,
quizOption.Order);
public static QuizOption ToEntity(this CreateQuizOptionDto dto, int quizQuestionId) =>
QuizOption.Create(
quizQuestionId,
dto.Text,
dto.IsCorrect,
dto.Order);
public static void UpdateFromDto(this QuizOption quizOption, UpdateQuizOptionDto dto)
{
quizOption.UpdateText(dto.Text);
quizOption.UpdateIsCorrect(dto.IsCorrect);
quizOption.UpdateOrder(dto.Order);
}
}

View file

@ -0,0 +1,420 @@
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;
}
}

View file

@ -0,0 +1,266 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a quiz question for a lesson.
/// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.)
/// and is associated with a specific lesson.
/// </summary>
public class QuizQuestion
{
public int Id { get; private set; }
public int LessonId { get; private set; }
public string QuestionText { get; private set; } = string.Empty;
public QuestionType Type { get; private set; }
public string? CorrectAnswer { get; private set; }
public int Difficulty { get; private set; } // 1-5 scale
public int Order { get; private set; }
public bool IsActive { get; private set; } = true;
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// Navigation property
public virtual Lesson? Lesson { get; private set; }
public virtual ICollection<QuizOption> Options { get; private set; } = new List<QuizOption>();
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private QuizQuestion() { }
/// <summary>
/// Factory method to create a new quiz question.
/// </summary>
/// <param name="lessonId">ID of the parent lesson</param>
/// <param name="questionText">The question text</param>
/// <param name="type">The question type</param>
/// <param name="correctAnswer">The correct answer</param>
/// <param name="difficulty">Difficulty level (1-5)</param>
/// <param name="order">Sort order within the lesson</param>
public static QuizQuestion Create(
int lessonId,
string questionText,
QuestionType type,
string correctAnswer,
int difficulty = 3,
int order = 1)
{
if (difficulty < 1 || difficulty > 5)
throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5");
if (string.IsNullOrWhiteSpace(questionText))
throw new ArgumentException("Question text cannot be empty", nameof(questionText));
return new QuizQuestion
{
LessonId = lessonId,
QuestionText = questionText,
Type = type,
CorrectAnswer = correctAnswer,
Difficulty = difficulty,
Order = order,
CreatedAt = DateTime.UtcNow
};
}
/// <summary>
/// Updates the question text.
/// </summary>
public void UpdateQuestionText(string newQuestionText)
{
if (string.IsNullOrWhiteSpace(newQuestionText))
throw new ArgumentException("Question text cannot be empty", nameof(newQuestionText));
QuestionText = newQuestionText;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the correct answer.
/// </summary>
public void UpdateCorrectAnswer(string newCorrectAnswer)
{
CorrectAnswer = newCorrectAnswer;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the difficulty level.
/// </summary>
public void UpdateDifficulty(int newDifficulty)
{
if (newDifficulty < 1 || newDifficulty > 5)
throw new ArgumentOutOfRangeException(nameof(newDifficulty), "Difficulty must be between 1 and 5");
Difficulty = newDifficulty;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the sort order.
/// </summary>
public void UpdateOrder(int newOrder)
{
Order = newOrder;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the question type.
/// </summary>
public void UpdateType(QuestionType newType)
{
Type = newType;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the associated lesson.
/// </summary>
public void UpdateLesson(int newLessonId)
{
LessonId = newLessonId;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Activates the quiz question.
/// </summary>
public void Activate()
{
IsActive = true;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Deactivates the quiz question.
/// </summary>
public void Deactivate()
{
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Adds an option to this question.
/// </summary>
public void AddOption(QuizOption option)
{
Options.Add(option);
}
/// <summary>
/// Removes an option from this question.
/// </summary>
public void RemoveOption(QuizOption option)
{
Options.Remove(option);
}
}
/// <summary>
/// Represents an option for a multiple-choice quiz question.
/// </summary>
public class QuizOption
{
public int Id { get; private set; }
public int QuizQuestionId { get; private set; }
public string Text { get; private set; } = string.Empty;
public bool IsCorrect { get; private set; }
public int Order { get; private set; }
// Navigation property
public virtual QuizQuestion? QuizQuestion { get; private set; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private QuizOption() { }
/// <summary>
/// Factory method to create a new quiz option.
/// </summary>
/// <param name="quizQuestionId">ID of the parent quiz question</param>
/// <param name="text">The option text</param>
/// <param name="isCorrect">Whether this is the correct answer</param>
/// <param name="order">Sort order within the question's options</param>
public static QuizOption Create(
int quizQuestionId,
string text,
bool isCorrect = false,
int order = 1)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("Option text cannot be empty", nameof(text));
return new QuizOption
{
QuizQuestionId = quizQuestionId,
Text = text,
IsCorrect = isCorrect,
Order = order
};
}
/// <summary>
/// Updates the option text.
/// </summary>
public void UpdateText(string newText)
{
if (string.IsNullOrWhiteSpace(newText))
throw new ArgumentException("Option text cannot be empty", nameof(newText));
Text = newText;
}
/// <summary>
/// Updates whether this option is correct.
/// </summary>
public void UpdateIsCorrect(bool newIsCorrect)
{
IsCorrect = newIsCorrect;
}
/// <summary>
/// Updates the sort order.
/// </summary>
public void UpdateOrder(int newOrder)
{
Order = newOrder;
}
}
/// <summary>
/// Enum representing the types of quiz questions.
/// </summary>
public enum QuestionType
{
/// <summary>
/// Multiple choice with single correct answer
/// </summary>
MultipleChoice = 0,
/// <summary>
/// Multiple choice with multiple correct answers
/// </summary>
MultipleSelect = 1,
/// <summary>
/// True or false question
/// </summary>
TrueFalse = 2,
/// <summary>
/// Fill-in-the-blank question
/// </summary>
FillInTheBlank = 3,
/// <summary>
/// Matching question
/// </summary>
Matching = 4,
/// <summary>
/// Short answer question
/// </summary>
ShortAnswer = 5
}

View file

@ -133,3 +133,75 @@ public interface IUserProgressRepository : IRepository<UserProgress, int>
/// </summary> /// </summary>
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default); Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
} }
/// <summary>
/// Repository interface for QuizQuestion entities.
/// </summary>
public interface IQuizQuestionRepository : IRepository<QuizQuestion, int>
{
/// <summary>
/// Gets quiz questions by lesson ID.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all quiz questions ordered by lesson and question order.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets active quiz questions by lesson ID.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a random set of quiz questions for a lesson.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default);
/// <summary>
/// Gets quiz questions by difficulty level.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default);
/// <summary>
/// Gets quiz questions by type.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a quiz question with the given order exists in a lesson.
/// </summary>
Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for QuizOption entities.
/// </summary>
public interface IQuizOptionRepository : IRepository<QuizOption, int>
{
/// <summary>
/// Gets quiz options by question ID.
/// </summary>
Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the correct option for a question.
/// </summary>
Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all correct options for a question (for MultipleSelect type).
/// </summary>
Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes all options for a question.
/// </summary>
Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if the order exists for a question.
/// </summary>
Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
}

View file

@ -19,6 +19,8 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
public DbSet<User> Users { get; set; } = null!; public DbSet<User> Users { get; set; } = null!;
public DbSet<UserProgress> UserProgress { get; set; } = null!; public DbSet<UserProgress> UserProgress { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!; public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
public DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
public DbSet<QuizOption> QuizOptions { get; set; } = null!;
// Note: Value objects are not stored directly as entities. // Note: Value objects are not stored directly as entities.
// They are owned by entities and stored as part of the entity's data. // They are owned by entities and stored as part of the entity's data.
@ -132,6 +134,55 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// Configure QuizQuestion entity
modelBuilder.Entity<QuizQuestion>(builder =>
{
builder.HasKey(q => q.Id);
builder.Property(q => q.LessonId).IsRequired();
builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000);
builder.Property(q => q.Type).IsRequired();
builder.Property(q => q.CorrectAnswer).HasMaxLength(1000);
builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3);
builder.Property(q => q.Order).IsRequired().HasDefaultValue(1);
builder.Property(q => q.IsActive).HasDefaultValue(true);
builder.Property(q => q.CreatedAt).IsRequired();
builder.Property(q => q.UpdatedAt).IsRequired(false);
// Foreign key to Lesson
builder.HasOne(q => q.Lesson)
.WithMany()
.HasForeignKey(q => q.LessonId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one question per lesson per order
builder.HasIndex(q => new { q.LessonId, q.Order }).IsUnique();
// Navigation to options
builder.HasMany(q => q.Options)
.WithOne(o => o.QuizQuestion)
.HasForeignKey(o => o.QuizQuestionId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure QuizOption entity
modelBuilder.Entity<QuizOption>(builder =>
{
builder.HasKey(o => o.Id);
builder.Property(o => o.QuizQuestionId).IsRequired();
builder.Property(o => o.Text).IsRequired().HasMaxLength(1000);
builder.Property(o => o.IsCorrect).IsRequired().HasDefaultValue(false);
builder.Property(o => o.Order).IsRequired().HasDefaultValue(1);
// Foreign key to QuizQuestion
builder.HasOne(o => o.QuizQuestion)
.WithMany(q => q.Options)
.HasForeignKey(o => o.QuizQuestionId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one option per question per order
builder.HasIndex(o => new { o.QuizQuestionId, o.Order }).IsUnique();
});
// Seed data (optional) - Note: For EF Core, we need to set properties directly // Seed data (optional) - Note: For EF Core, we need to set properties directly
// In a real application, use migrations or a separate seeding mechanism // In a real application, use migrations or a separate seeding mechanism
// modelBuilder.Entity<Lesson>().HasData( // modelBuilder.Entity<Lesson>().HasData(

View file

@ -0,0 +1,390 @@
// <auto-generated />
using System;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260612050652_AddQuizQuestionTables")]
partial class AddQuizQuestionTables
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("LevelId")
.HasColumnType("integer");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LevelId", "Order")
.IsUnique();
b.ToTable("Lessons");
});
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Order")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Order")
.IsUnique();
b.ToTable("Levels");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsCorrect")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("QuizQuestionId")
.HasColumnType("integer");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("QuizQuestionId", "Order")
.IsUnique();
b.ToTable("QuizOptions");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CorrectAnswer")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Difficulty")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3);
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("LessonId")
.HasColumnType("integer");
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("QuestionText")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LessonId", "Order")
.IsUnique();
b.ToTable("QuizQuestions");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentLevel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("A1");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("Streak")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("TotalPoints")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTime>("LastAttemptDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("LessonId")
.HasColumnType("integer");
b.Property<int>("QuizScore")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("LessonId");
b.HasIndex("UserId", "LessonId")
.IsUnique();
b.ToTable("UserProgress");
});
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
.WithMany()
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
{
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
.WithMany("Options")
.HasForeignKey("QuizQuestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("QuizQuestion");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
{
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
.WithMany()
.HasForeignKey("LessonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lesson");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{
b.HasOne("GermanApp.Domain.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
{
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
.WithMany()
.HasForeignKey("LessonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GermanApp.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lesson");
b.Navigation("User");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
{
b.Navigation("Options");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddQuizQuestionTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "QuizQuestions",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LessonId = table.Column<int>(type: "integer", nullable: false),
QuestionText = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
CorrectAnswer = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Difficulty = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_QuizQuestions", x => x.Id);
table.ForeignKey(
name: "FK_QuizQuestions_Lessons_LessonId",
column: x => x.LessonId,
principalTable: "Lessons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "QuizOptions",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
QuizQuestionId = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
IsCorrect = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1)
},
constraints: table =>
{
table.PrimaryKey("PK_QuizOptions", x => x.Id);
table.ForeignKey(
name: "FK_QuizOptions_QuizQuestions_QuizQuestionId",
column: x => x.QuizQuestionId,
principalTable: "QuizQuestions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_QuizOptions_QuizQuestionId_Order",
table: "QuizOptions",
columns: new[] { "QuizQuestionId", "Order" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_QuizQuestions_LessonId_Order",
table: "QuizQuestions",
columns: new[] { "LessonId", "Order" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "QuizOptions");
migrationBuilder.DropTable(
name: "QuizQuestions");
}
}
}

View file

@ -102,6 +102,92 @@ namespace GermanApp.Migrations
b.ToTable("Levels"); b.ToTable("Levels");
}); });
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsCorrect")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("QuizQuestionId")
.HasColumnType("integer");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("QuizQuestionId", "Order")
.IsUnique();
b.ToTable("QuizOptions");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CorrectAnswer")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Difficulty")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3);
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("LessonId")
.HasColumnType("integer");
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("QuestionText")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LessonId", "Order")
.IsUnique();
b.ToTable("QuizQuestions");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b => modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -241,6 +327,28 @@ namespace GermanApp.Migrations
b.Navigation("Level"); b.Navigation("Level");
}); });
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
{
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
.WithMany("Options")
.HasForeignKey("QuizQuestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("QuizQuestion");
});
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
{
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
.WithMany()
.HasForeignKey("LessonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lesson");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b => modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{ {
b.HasOne("GermanApp.Domain.Entities.User", null) b.HasOne("GermanApp.Domain.Entities.User", null)
@ -268,6 +376,11 @@ namespace GermanApp.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
{
b.Navigation("Options");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View file

@ -0,0 +1,112 @@
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.Repositories;
/// <summary>
/// Entity Framework Core implementation of IQuizOptionRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class QuizOptionRepository : IQuizOptionRepository
{
private readonly AppDbContext _context;
public QuizOptionRepository(AppDbContext context)
{
_context = context;
}
public async Task<QuizOption?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.QuizOptions
.Include(o => o.QuizQuestion)
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<QuizOption>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.QuizOptions
.Include(o => o.QuizQuestion)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<QuizOption> AddAsync(QuizOption entity, CancellationToken cancellationToken = default)
{
await _context.QuizOptions.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(QuizOption entity, CancellationToken cancellationToken = default)
{
_context.QuizOptions.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(QuizOption entity, CancellationToken cancellationToken = default)
{
_context.QuizOptions.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.QuizOptions
.AnyAsync(o => o.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
{
return await _context.QuizOptions
.Include(o => o.QuizQuestion)
.Where(o => o.QuizQuestionId == questionId)
.OrderBy(o => o.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
{
return await _context.QuizOptions
.Include(o => o.QuizQuestion)
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
.OrderBy(o => o.Order)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
{
return await _context.QuizOptions
.Include(o => o.QuizQuestion)
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
.OrderBy(o => o.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
{
var options = await _context.QuizOptions
.Where(o => o.QuizQuestionId == questionId)
.ToListAsync(cancellationToken);
_context.QuizOptions.RemoveRange(options);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
{
var query = _context.QuizOptions
.Where(o => o.QuizQuestionId == questionId && o.Order == order);
if (excludeId.HasValue)
{
query = query.Where(o => o.Id != excludeId.Value);
}
return await query.AnyAsync(cancellationToken);
}
}

View file

@ -0,0 +1,142 @@
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.Repositories;
/// <summary>
/// Entity Framework Core implementation of IQuizQuestionRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class QuizQuestionRepository : IQuizQuestionRepository
{
private readonly AppDbContext _context;
public QuizQuestionRepository(AppDbContext context)
{
_context = context;
}
public async Task<QuizQuestion?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order))
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<QuizQuestion> AddAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
{
await _context.QuizQuestions.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
{
_context.QuizQuestions.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
{
_context.QuizQuestions.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.AnyAsync(q => q.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.LessonId == lessonId)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order))
.OrderBy(q => q.Lesson.Order)
.ThenBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.LessonId == lessonId && q.IsActive)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.LessonId == lessonId && q.IsActive)
.OrderBy(q => Guid.NewGuid()) // Random ordering
.Take(count)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options)
.Where(q => q.Difficulty == difficulty)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Options)
.Where(q => q.Type == type)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
{
var query = _context.QuizQuestions
.Where(q => q.LessonId == lessonId && q.Order == order);
if (excludeId.HasValue)
{
query = query.Where(q => q.Id != excludeId.Value);
}
return await query.AnyAsync(cancellationToken);
}
}

View file

@ -66,7 +66,47 @@ public static class SeedDataExtension
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons."); // Seed quiz questions for the lessons
dbContext.QuizQuestions.AddRange(new[]
{
// Questions for Greetings lesson (Lesson 1)
QuizQuestion.Create(1, "What is the German word for 'Hello'?", QuestionType.FillInTheBlank, "Hallo", 1, 1),
QuizQuestion.Create(1, "How do you say 'Good morning' in German?", QuestionType.FillInTheBlank, "Guten Morgen", 2, 2),
QuizQuestion.Create(1, "What is the German word for 'Goodbye'?", QuestionType.MultipleChoice, "Auf Wiedersehen", 1, 3),
// Questions for Numbers lesson (Lesson 2)
QuizQuestion.Create(2, "What is '1' in German?", QuestionType.FillInTheBlank, "eins", 1, 1),
QuizQuestion.Create(2, "What is '10' in German?", QuestionType.FillInTheBlank, "zehn", 2, 2),
QuizQuestion.Create(2, "Which number is 'zwanzig'?", QuestionType.MultipleChoice, "20", 2, 3),
// Questions for Grammar Basics lesson (Lesson 3)
QuizQuestion.Create(3, "Is 'Der Mann' masculine?", QuestionType.TrueFalse, "True", 3, 1),
QuizQuestion.Create(3, "What is the article for 'Frau'?", QuestionType.MultipleChoice, "die", 2, 2)
});
await dbContext.SaveChangesAsync();
// Get the quiz question IDs after save
var quizQuestions = await dbContext.QuizQuestions.OrderBy(q => q.Id).ToListAsync();
// Add options for multiple choice questions
// greetingsQ3 is ID 3 (3rd question created)
dbContext.QuizOptions.AddRange(new[]
{
QuizOption.Create(quizQuestions[2].Id, "Hallo", false, 1),
QuizOption.Create(quizQuestions[2].Id, "Danke", false, 2),
QuizOption.Create(quizQuestions[2].Id, "Auf Wiedersehen", true, 3),
QuizOption.Create(quizQuestions[2].Id, "Bitte", false, 4),
// Numbers Q3 is ID 6
QuizOption.Create(quizQuestions[5].Id, "10", false, 1),
QuizOption.Create(quizQuestions[5].Id, "20", true, 2),
QuizOption.Create(quizQuestions[5].Id, "30", false, 3),
QuizOption.Create(quizQuestions[5].Id, "100", false, 4),
// Grammar Q2 is ID 8
QuizOption.Create(quizQuestions[7].Id, "der", false, 1),
QuizOption.Create(quizQuestions[7].Id, "die", true, 2),
QuizOption.Create(quizQuestions[7].Id, "das", false, 3)
});
await dbContext.SaveChangesAsync();
Console.WriteLine("Database seeded with levels, admin user, test user, sample lessons, and quiz questions.");
} }
} }
} }

View file

@ -0,0 +1,287 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace GermanApp.Presentation.Controllers;
/// <summary>
/// API controller for managing quiz questions.
/// This is part of the Presentation layer.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class QuizQuestionsController : ControllerBase
{
private readonly QuizQuestionService _quizQuestionService;
public QuizQuestionsController(QuizQuestionService quizQuestionService)
{
_quizQuestionService = quizQuestionService;
}
/// <summary>
/// Gets all quiz questions.
/// </summary>
/// <returns>List of all quiz questions</returns>
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets all active quiz questions for a specific lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>List of quiz questions for the specified lesson</returns>
[HttpGet("by-lesson/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets a specific quiz question by its ID.
/// </summary>
/// <param name="id">The quiz question ID</param>
/// <returns>The quiz question with the specified ID</returns>
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
{
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
if (question == null)
return NotFound();
return Ok(question);
}
/// <summary>
/// Gets a random set of quiz questions for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <param name="count">Number of questions to return</param>
/// <returns>List of random quiz questions for the lesson</returns>
[HttpGet("random/{lessonId}/{count}")]
[AllowAnonymous]
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets quiz questions by difficulty level.
/// </summary>
/// <param name="difficulty">The difficulty level (1-5)</param>
/// <returns>List of quiz questions with the specified difficulty</returns>
[HttpGet("by-difficulty/{difficulty}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets quiz questions by type.
/// </summary>
/// <param name="type">The question type</param>
/// <returns>List of quiz questions with the specified type</returns>
[HttpGet("by-type/{type}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Creates a new quiz question.
/// </summary>
/// <param name="dto">The quiz question data</param>
/// <returns>The created quiz question</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
{
var question = await _quizQuestionService.CreateQuizQuestionAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetQuizQuestionByIdAsync), new { id = question.Id }, question);
}
/// <summary>
/// Updates an existing quiz question.
/// </summary>
/// <param name="id">The quiz question ID</param>
/// <param name="dto">The updated quiz question data</param>
/// <returns>The updated quiz question</returns>
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
{
var question = await _quizQuestionService.UpdateQuizQuestionAsync(id, dto, cancellationToken);
if (question == null)
return NotFound();
return Ok(question);
}
/// <summary>
/// Deletes a quiz question by its ID.
/// </summary>
/// <param name="id">The quiz question ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
{
var result = await _quizQuestionService.DeleteQuizQuestionAsync(id, cancellationToken);
if (!result)
return NotFound();
return NoContent();
}
/// <summary>
/// Gets quiz options for a specific question.
/// </summary>
/// <param name="questionId">The question ID</param>
/// <returns>List of quiz options for the question</returns>
[HttpGet("options/{questionId}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
{
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
return Ok(options);
}
/// <summary>
/// Gets the correct option for a question.
/// </summary>
/// <param name="questionId">The question ID</param>
/// <returns>The correct option for the question</returns>
[HttpGet("correct-option/{questionId}")]
[AllowAnonymous]
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
{
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
if (option == null)
return NotFound();
return Ok(option);
}
/// <summary>
/// Gets all correct options for a question (for MultipleSelect type).
/// </summary>
/// <param name="questionId">The question ID</param>
/// <returns>List of correct options for the question</returns>
[HttpGet("correct-options/{questionId}")]
[AllowAnonymous]
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
{
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
return Ok(options);
}
/// <summary>
/// Creates a new quiz option for a question.
/// </summary>
/// <param name="questionId">The question ID</param>
/// <param name="dto">The quiz option data</param>
/// <returns>The created quiz option</returns>
[HttpPost("options/{questionId}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
{
var option = await _quizQuestionService.CreateQuizOptionAsync(questionId, dto, cancellationToken);
return CreatedAtAction(nameof(GetQuizOptionsByQuestionAsync), new { questionId }, option);
}
/// <summary>
/// Updates an existing quiz option.
/// </summary>
/// <param name="id">The quiz option ID</param>
/// <param name="dto">The updated quiz option data</param>
/// <returns>The updated quiz option</returns>
[HttpPut("options/{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
{
var option = await _quizQuestionService.UpdateQuizOptionAsync(id, dto, cancellationToken);
if (option == null)
return NotFound();
return Ok(option);
}
/// <summary>
/// Deletes a quiz option by its ID.
/// </summary>
/// <param name="id">The quiz option ID</param>
/// <returns>No content on success</returns>
[HttpDelete("options/{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
{
var result = await _quizQuestionService.DeleteQuizOptionAsync(id, cancellationToken);
if (!result)
return NotFound();
return NoContent();
}
/// <summary>
/// Submits quiz answers and returns the result.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="dto">The quiz answers submission</param>
/// <returns>The quiz result</returns>
[HttpPost("submit/{userId}")]
[Authorize]
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
{
var result = await _quizQuestionService.SubmitQuizAnswersAsync(userId, dto, cancellationToken);
return Ok(result);
}
/// <summary>
/// Gets the number of quiz questions for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>The count of quiz questions for the lesson</returns>
[HttpGet("count/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
return Ok(new { lessonId, count });
}
/// <summary>
/// Gets the difficulty distribution for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>The difficulty distribution for the lesson</returns>
[HttpGet("difficulty-distribution/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
{
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
return Ok(distribution);
}
/// <summary>
/// Gets the type distribution for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>The type distribution for the lesson</returns>
[HttpGet("type-distribution/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
{
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
return Ok(distribution);
}
}

View file

@ -134,6 +134,8 @@ try
builder.Services.AddScoped<ILevelRepository, LevelRepository>(); builder.Services.AddScoped<ILevelRepository, LevelRepository>();
builder.Services.AddScoped<ILessonRepository, LessonRepository>(); builder.Services.AddScoped<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>(); builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
// ============================================ // ============================================
// INFRASTRUCTURE LAYER - AI Services // INFRASTRUCTURE LAYER - AI Services
@ -169,6 +171,7 @@ try
builder.Services.AddScoped<LevelService>(); builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>(); builder.Services.AddScoped<LessonService>();
builder.Services.AddScoped<ProgressService>(); builder.Services.AddScoped<ProgressService>();
builder.Services.AddScoped<QuizQuestionService>();
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>(); builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
var app = builder.Build(); var app = builder.Build();
@ -204,10 +207,20 @@ try
app.MapLessonsEndpoints(); app.MapLessonsEndpoints();
// Seed database with initial data // Seed database with initial data
if (app.Environment.IsDevelopment()) // Run migrations and seed in Development, or just migrations in Production/Docker
// In Docker, we can control seeding via environment variable
var shouldSeed = app.Environment.IsDevelopment() ||
(app.Configuration.GetValue<bool>("SeedDatabase"));
if (shouldSeed)
{ {
await app.SeedDatabaseAsync(); await app.SeedDatabaseAsync();
} }
else
{
// In Production/Docker, ensure migrations are applied with retry logic
await ApplyMigrationsWithRetry(app, maxRetries: 10, delaySeconds: 5);
}
// Keep original WeatherForecast endpoint for reference // Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () => app.MapGet("/weatherforecast", () =>
@ -230,6 +243,36 @@ try
.WithName("GetWeatherForecast"); .WithName("GetWeatherForecast");
app.Run(); app.Run();
// Helper method to apply migrations with retry logic for Docker
static async Task ApplyMigrationsWithRetry(WebApplication app, int maxRetries, int delaySeconds)
{
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.Migrate();
Log.Information("Database migrations applied in Production environment");
return;
}
catch (Exception ex)
{
retryCount++;
Log.Warning(ex, "Failed to apply database migrations (attempt {Attempt}/{MaxAttempts}). Retrying in {DelaySeconds} seconds...",
retryCount, maxRetries, delaySeconds);
if (retryCount >= maxRetries)
{
Log.Fatal(ex, "Failed to apply database migrations after {MaxAttempts} attempts", maxRetries);
throw;
}
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
}
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace GermanApp.Tests.Unit.Application.Services;
[TestClass]
public class QuizQuestionServiceTests
{
private Mock<IQuizQuestionRepository> _mockQuizQuestionRepo;
private Mock<IQuizOptionRepository> _mockQuizOptionRepo;
private Mock<ILessonRepository> _mockLessonRepo;
private QuizQuestionService _service;
[TestInitialize]
public void Setup()
{
_mockQuizQuestionRepo = new Mock<IQuizQuestionRepository>();
_mockQuizOptionRepo = new Mock<IQuizOptionRepository>();
_mockLessonRepo = new Mock<ILessonRepository>();
_service = new QuizQuestionService(
_mockQuizQuestionRepo.Object,
_mockQuizOptionRepo.Object,
_mockLessonRepo.Object);
}
[TestMethod]
public async Task GetAllQuizQuestionsAsync_ReturnsMappedDtoList()
{
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
var questions = new List<QuizQuestion>
{
QuizQuestion.Create(1, "Question 1", QuestionType.MultipleChoice, "Answer 1", 3, 1)
};
_mockQuizQuestionRepo.Setup(r => r.GetAllOrderedAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(questions);
var result = await _service.GetAllQuizQuestionsAsync();
Assert.AreEqual(1, result.Count);
Assert.AreEqual("Question 1", result[0].QuestionText);
}
[TestMethod]
public async Task GetQuizQuestionByIdAsync_WithExistingId_ReturnsQuestion()
{
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Test Answer", 3, 1);
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(question);
var result = await _service.GetQuizQuestionByIdAsync(1);
Assert.IsNotNull(result);
Assert.AreEqual("Test Question", result.QuestionText);
}
[TestMethod]
public async Task GetQuizQuestionByIdAsync_WithNonExistingId_ReturnsNull()
{
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((QuizQuestion?)null);
var result = await _service.GetQuizQuestionByIdAsync(999);
Assert.IsNull(result);
}
[TestMethod]
public async Task GetQuizQuestionsByLessonAsync_ReturnsFilteredQuestions()
{
var questions = new List<QuizQuestion>
{
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1)
};
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(questions);
var result = await _service.GetQuizQuestionsByLessonAsync(1);
Assert.AreEqual(1, result.Count);
Assert.AreEqual("Q1", result[0].QuestionText);
}
[TestMethod]
public async Task GetActiveQuizQuestionsByLessonAsync_ReturnsOnlyActive()
{
var activeQuestion = QuizQuestion.Create(1, "Active", QuestionType.MultipleChoice, "A1", 3, 1);
var inactiveQuestion = QuizQuestion.Create(1, "Inactive", QuestionType.MultipleChoice, "A2", 3, 2);
inactiveQuestion.Deactivate();
var questions = new List<QuizQuestion> { activeQuestion, inactiveQuestion };
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<QuizQuestion> { activeQuestion });
var result = await _service.GetActiveQuizQuestionsByLessonAsync(1);
Assert.AreEqual(1, result.Count);
Assert.AreEqual("Active", result[0].QuestionText);
}
[TestMethod]
public async Task CreateQuizQuestionAsync_WithValidDtoAndValidLesson_CreatesQuestion()
{
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
var options = new List<CreateQuizOptionDto>
{
new CreateQuizOptionDto("Option 1", true, 1),
new CreateQuizOptionDto("Option 2", false, 2)
};
var dto = new CreateQuizQuestionDto(
1, "New Question", QuestionType.MultipleChoice, "Answer 1", 3, 1, options);
var createdQuestion = QuizQuestion.Create(1, "New Question", QuestionType.MultipleChoice, "Answer 1", 3, 1);
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(lesson);
_mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockQuizQuestionRepo.Setup(r => r.AddAsync(It.IsAny<QuizQuestion>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(createdQuestion);
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(createdQuestion.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(createdQuestion);
var result = await _service.CreateQuizQuestionAsync(dto);
Assert.IsNotNull(result);
Assert.AreEqual("New Question", result.QuestionText);
}
[TestMethod]
public async Task UpdateQuizQuestionAsync_WithExistingQuestion_UpdatesQuestion()
{
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
var existing = QuizQuestion.Create(1, "Old Question", QuestionType.MultipleChoice, "Old Answer", 3, 1);
var options = new List<UpdateQuizOptionDto>
{
new UpdateQuizOptionDto(1, "Updated Option", true, 1)
};
var dto = new UpdateQuizQuestionDto(1, "Updated Question", QuestionType.TrueFalse, "New Answer", 4, 1, true, options);
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(existing);
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(lesson);
_mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockQuizQuestionRepo.Setup(r => r.UpdateAsync(existing, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(existing);
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var result = await _service.UpdateQuizQuestionAsync(1, dto);
Assert.IsNotNull(result);
Assert.AreEqual("Updated Question", result.QuestionText);
}
[TestMethod]
public async Task DeleteQuizQuestionAsync_WithExistingQuestion_ReturnsTrue()
{
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(question);
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_mockQuizQuestionRepo.Setup(r => r.DeleteAsync(question, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var result = await _service.DeleteQuizQuestionAsync(1);
Assert.IsTrue(result);
}
[TestMethod]
public async Task GetQuizOptionsByQuestionAsync_ReturnsOptions()
{
var options = new List<QuizOption>
{
QuizOption.Create(1, "Option 1", true, 1),
QuizOption.Create(1, "Option 2", false, 2)
};
_mockQuizOptionRepo.Setup(r => r.GetByQuestionAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(options);
var result = await _service.GetQuizOptionsByQuestionAsync(1);
Assert.AreEqual(2, result.Count);
}
[TestMethod]
public async Task GetCorrectOptionAsync_ReturnsCorrectOption()
{
var option = QuizOption.Create(1, "Correct Option", true, 1);
_mockQuizOptionRepo.Setup(r => r.GetCorrectOptionAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(option);
var result = await _service.GetCorrectOptionAsync(1);
Assert.IsNotNull(result);
Assert.IsTrue(result.IsCorrect);
}
[TestMethod]
public async Task CreateQuizOptionAsync_WithValidData_CreatesOption()
{
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
var dto = new CreateQuizOptionDto("New Option", true, 1);
var createdOption = QuizOption.Create(1, "New Option", true, 1);
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(question);
_mockQuizOptionRepo.Setup(r => r.OrderExistsForQuestionAsync(1, 1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockQuizOptionRepo.Setup(r => r.AddAsync(It.IsAny<QuizOption>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(createdOption);
var result = await _service.CreateQuizOptionAsync(1, dto);
Assert.IsNotNull(result);
Assert.AreEqual("New Option", result.Text);
}
[TestMethod]
public async Task SubmitQuizAnswersAsync_WithCorrectAnswers_ReturnsPassedResult()
{
var question = QuizQuestion.Create(1, "What is 2+2?", QuestionType.FillInTheBlank, "4", 3, 1);
var questions = new List<QuizQuestion> { question };
var answers = new List<QuizAnswerDto>
{
new QuizAnswerDto(1, "4", null)
};
var dto = new SubmitQuizAnswersDto(1, answers);
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(questions);
var result = await _service.SubmitQuizAnswersAsync(1, dto);
Assert.AreEqual(1, result.TotalQuestions);
Assert.AreEqual(1, result.CorrectAnswers);
Assert.AreEqual(100, result.ScorePercentage);
Assert.IsTrue(result.Passed);
}
[TestMethod]
public async Task SubmitQuizAnswersAsync_WithIncorrectAnswers_ReturnsFailedResult()
{
var question = QuizQuestion.Create(1, "What is 2+2?", QuestionType.FillInTheBlank, "4", 3, 1);
var questions = new List<QuizQuestion> { question };
var answers = new List<QuizAnswerDto>
{
new QuizAnswerDto(1, "5", null) // Wrong answer
};
var dto = new SubmitQuizAnswersDto(1, answers);
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(questions);
var result = await _service.SubmitQuizAnswersAsync(1, dto);
Assert.AreEqual(1, result.TotalQuestions);
Assert.AreEqual(0, result.CorrectAnswers);
Assert.AreEqual(0, result.ScorePercentage);
Assert.IsFalse(result.Passed);
}
[TestMethod]
public async Task GetQuizQuestionCountForLessonAsync_ReturnsCount()
{
var questions = new List<QuizQuestion>
{
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1),
QuizQuestion.Create(1, "Q2", QuestionType.MultipleChoice, "A2", 3, 2)
};
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(questions);
var result = await _service.GetQuizQuestionCountForLessonAsync(1);
Assert.AreEqual(2, result);
}
[TestMethod]
public async Task GetDifficultyDistributionAsync_ReturnsDistribution()
{
var questions = new List<QuizQuestion>
{
QuizQuestion.Create(1, "Easy", QuestionType.MultipleChoice, "A1", 1, 1),
QuizQuestion.Create(1, "Medium", QuestionType.MultipleChoice, "A2", 3, 2),
QuizQuestion.Create(1, "Hard", QuestionType.MultipleChoice, "A3", 5, 3)
};
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(questions);
var result = await _service.GetDifficultyDistributionAsync(1);
Assert.AreEqual(1, result[1]); // Easy
Assert.AreEqual(0, result[2]); // No questions with difficulty 2
Assert.AreEqual(1, result[3]); // Medium
Assert.AreEqual(0, result[4]); // No questions with difficulty 4
Assert.AreEqual(1, result[5]); // Hard
}
}

View file

@ -28,6 +28,7 @@ services:
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: Host=db;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres ConnectionStrings__DefaultConnection: Host=db;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres
SeedDatabase: "true"
# JWT Configuration (from Woodpecker secrets or environment) # JWT Configuration (from Woodpecker secrets or environment)
Jwt__Key: ${JWT_KEY:-your-super-secret-key-at-least-32-characters-long} Jwt__Key: ${JWT_KEY:-your-super-secret-key-at-least-32-characters-long}
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen} Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}