diff --git a/GermanApp/Application/DTOs/QuizQuestionDto.cs b/GermanApp/Application/DTOs/QuizQuestionDto.cs
new file mode 100644
index 0000000..ccfc6db
--- /dev/null
+++ b/GermanApp/Application/DTOs/QuizQuestionDto.cs
@@ -0,0 +1,191 @@
+using GermanApp.Domain.Entities;
+
+namespace GermanApp.Application.DTOs;
+
+///
+/// Data Transfer Object for QuizQuestion - used for API responses.
+/// This is a read-only representation of a QuizQuestion entity.
+///
+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 Options);
+
+///
+/// Data Transfer Object for creating a new QuizQuestion.
+///
+public record CreateQuizQuestionDto(
+ int LessonId,
+ string QuestionText,
+ QuestionType Type,
+ string? CorrectAnswer,
+ int Difficulty,
+ int Order,
+ IReadOnlyList? Options);
+
+///
+/// Data Transfer Object for updating an existing QuizQuestion.
+///
+public record UpdateQuizQuestionDto(
+ int LessonId,
+ string QuestionText,
+ QuestionType Type,
+ string? CorrectAnswer,
+ int Difficulty,
+ int Order,
+ bool IsActive,
+ IReadOnlyList? Options);
+
+///
+/// Data Transfer Object for QuizOption - used for API responses.
+///
+public record QuizOptionDto(
+ int Id,
+ int QuizQuestionId,
+ string Text,
+ bool IsCorrect,
+ int Order);
+
+///
+/// Data Transfer Object for creating a new QuizOption.
+///
+public record CreateQuizOptionDto(
+ string Text,
+ bool IsCorrect,
+ int Order);
+
+///
+/// Data Transfer Object for updating an existing QuizOption.
+///
+public record UpdateQuizOptionDto(
+ int Id,
+ string Text,
+ bool IsCorrect,
+ int Order);
+
+///
+/// Data Transfer Object for quiz question with user's answer.
+///
+public record QuizQuestionWithAnswerDto(
+ int QuizQuestionId,
+ int LessonId,
+ string QuestionText,
+ QuestionType Type,
+ IReadOnlyList Options,
+ string? UserAnswer,
+ bool IsCorrect);
+
+///
+/// Data Transfer Object for submitting quiz answers.
+///
+public record SubmitQuizAnswersDto(
+ int LessonId,
+ IReadOnlyList Answers);
+
+///
+/// Data Transfer Object for a single quiz answer submission.
+///
+public record QuizAnswerDto(
+ int QuizQuestionId,
+ string? AnswerText,
+ int[]? SelectedOptionIds);
+
+///
+/// Data Transfer Object for quiz result.
+///
+public record QuizResultDto(
+ int LessonId,
+ int TotalQuestions,
+ int CorrectAnswers,
+ double ScorePercentage,
+ bool Passed,
+ TimeSpan TimeTaken,
+ IReadOnlyList QuestionResults);
+
+///
+/// Data Transfer Object for individual question result.
+///
+public record QuizQuestionResultDto(
+ int QuizQuestionId,
+ string QuestionText,
+ QuestionType Type,
+ bool IsCorrect,
+ string? CorrectAnswer,
+ string? UserAnswer);
+
+///
+/// Extension methods for mapping between QuizQuestion entity and DTOs.
+///
+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());
+
+ 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);
+ }
+}
diff --git a/GermanApp/Application/Services/QuizQuestionService.cs b/GermanApp/Application/Services/QuizQuestionService.cs
new file mode 100644
index 0000000..cefe363
--- /dev/null
+++ b/GermanApp/Application/Services/QuizQuestionService.cs
@@ -0,0 +1,420 @@
+using GermanApp.Application.DTOs;
+using GermanApp.Domain.Entities;
+using GermanApp.Domain.Interfaces;
+
+namespace GermanApp.Application.Services;
+
+///
+/// Application service for managing quiz questions.
+/// This is part of the Application layer.
+///
+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;
+ }
+
+ ///
+ /// Gets all quiz questions.
+ ///
+ public virtual async Task> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetAllOrderedAsync(cancellationToken);
+ return questions.Select(q => q.ToDto()).ToList();
+ }
+
+ ///
+ /// Gets a quiz question by its ID.
+ ///
+ public virtual async Task GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
+ return question?.ToDto();
+ }
+
+ ///
+ /// Gets quiz questions by lesson ID.
+ ///
+ public virtual async Task> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetByLessonAsync(lessonId, cancellationToken);
+ return questions.Select(q => q.ToDto()).ToList();
+ }
+
+ ///
+ /// Gets active quiz questions by lesson ID.
+ ///
+ public virtual async Task> GetActiveQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
+ return questions.Select(q => q.ToDto()).ToList();
+ }
+
+ ///
+ /// Gets a random set of quiz questions for a lesson.
+ ///
+ public virtual async Task> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetRandomQuestionsForLessonAsync(lessonId, count, cancellationToken);
+ return questions.Select(q => q.ToDto()).ToList();
+ }
+
+ ///
+ /// Gets quiz questions by difficulty level.
+ ///
+ public virtual async Task> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetByDifficultyAsync(difficulty, cancellationToken);
+ return questions.Select(q => q.ToDto()).ToList();
+ }
+
+ ///
+ /// Gets quiz questions by type.
+ ///
+ public virtual async Task> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetByTypeAsync(type, cancellationToken);
+ return questions.Select(q => q.ToDto()).ToList();
+ }
+
+ ///
+ /// Creates a new quiz question with its options.
+ ///
+ public virtual async Task 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();
+ }
+
+ ///
+ /// Updates an existing quiz question and its options.
+ ///
+ public virtual async Task 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();
+ }
+
+ ///
+ /// Validates that the question order is unique within its lesson.
+ ///
+ 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");
+ }
+
+ ///
+ /// Deletes a quiz question and its options by its ID.
+ ///
+ public virtual async Task 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;
+ }
+
+ ///
+ /// Gets quiz options for a specific question.
+ ///
+ public virtual async Task> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
+ {
+ var options = await _quizOptionRepository.GetByQuestionAsync(questionId, cancellationToken);
+ return options.Select(o => o.ToDto()).ToList();
+ }
+
+ ///
+ /// Gets the correct option for a question.
+ ///
+ public virtual async Task GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
+ {
+ var option = await _quizOptionRepository.GetCorrectOptionAsync(questionId, cancellationToken);
+ return option?.ToDto();
+ }
+
+ ///
+ /// Gets all correct options for a question (for MultipleSelect type).
+ ///
+ public virtual async Task> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
+ {
+ var options = await _quizOptionRepository.GetCorrectOptionsAsync(questionId, cancellationToken);
+ return options.Select(o => o.ToDto()).ToList();
+ }
+
+ ///
+ /// Creates a quiz option for a question.
+ ///
+ public virtual async Task 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();
+ }
+
+ ///
+ /// Updates an existing quiz option.
+ ///
+ public virtual async Task 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();
+ }
+
+ ///
+ /// Validates that the option order is unique within its question.
+ ///
+ 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");
+ }
+
+ ///
+ /// Deletes a quiz option by its ID.
+ ///
+ public virtual async Task 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;
+ }
+
+ ///
+ /// Submits quiz answers and returns the result.
+ ///
+ public virtual async Task 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();
+
+ // 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(correctOptions.Select(o => o.Id));
+ var selectedIds = new HashSet(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);
+ }
+
+ ///
+ /// Gets the number of quiz questions for a lesson.
+ ///
+ public virtual async Task GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
+ return questions.Count;
+ }
+
+ ///
+ /// Gets the difficulty distribution for a lesson.
+ ///
+ public virtual async Task> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
+ var distribution = new Dictionary();
+
+ for (int i = 1; i <= 5; i++)
+ {
+ distribution[i] = questions.Count(q => q.Difficulty == i);
+ }
+
+ return distribution;
+ }
+
+ ///
+ /// Gets the type distribution for a lesson.
+ ///
+ public virtual async Task> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
+ var distribution = new Dictionary();
+
+ foreach (var type in Enum.GetValues(typeof(QuestionType)).Cast())
+ {
+ distribution[type.ToString()] = questions.Count(q => q.Type == type);
+ }
+
+ return distribution;
+ }
+}
diff --git a/GermanApp/Domain/Entities/QuizQuestion.cs b/GermanApp/Domain/Entities/QuizQuestion.cs
new file mode 100644
index 0000000..8758dd4
--- /dev/null
+++ b/GermanApp/Domain/Entities/QuizQuestion.cs
@@ -0,0 +1,266 @@
+namespace GermanApp.Domain.Entities;
+
+///
+/// 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.
+///
+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 Options { get; private set; } = new List();
+
+ ///
+ /// Constructor for EF Core deserialization.
+ ///
+ private QuizQuestion() { }
+
+ ///
+ /// Factory method to create a new quiz question.
+ ///
+ /// ID of the parent lesson
+ /// The question text
+ /// The question type
+ /// The correct answer
+ /// Difficulty level (1-5)
+ /// Sort order within the lesson
+ 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
+ };
+ }
+
+ ///
+ /// Updates the question text.
+ ///
+ public void UpdateQuestionText(string newQuestionText)
+ {
+ if (string.IsNullOrWhiteSpace(newQuestionText))
+ throw new ArgumentException("Question text cannot be empty", nameof(newQuestionText));
+
+ QuestionText = newQuestionText;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the correct answer.
+ ///
+ public void UpdateCorrectAnswer(string newCorrectAnswer)
+ {
+ CorrectAnswer = newCorrectAnswer;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the difficulty level.
+ ///
+ 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;
+ }
+
+ ///
+ /// Updates the sort order.
+ ///
+ public void UpdateOrder(int newOrder)
+ {
+ Order = newOrder;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the question type.
+ ///
+ public void UpdateType(QuestionType newType)
+ {
+ Type = newType;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the associated lesson.
+ ///
+ public void UpdateLesson(int newLessonId)
+ {
+ LessonId = newLessonId;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Activates the quiz question.
+ ///
+ public void Activate()
+ {
+ IsActive = true;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Deactivates the quiz question.
+ ///
+ public void Deactivate()
+ {
+ IsActive = false;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Adds an option to this question.
+ ///
+ public void AddOption(QuizOption option)
+ {
+ Options.Add(option);
+ }
+
+ ///
+ /// Removes an option from this question.
+ ///
+ public void RemoveOption(QuizOption option)
+ {
+ Options.Remove(option);
+ }
+}
+
+///
+/// Represents an option for a multiple-choice quiz question.
+///
+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; }
+
+ ///
+ /// Constructor for EF Core deserialization.
+ ///
+ private QuizOption() { }
+
+ ///
+ /// Factory method to create a new quiz option.
+ ///
+ /// ID of the parent quiz question
+ /// The option text
+ /// Whether this is the correct answer
+ /// Sort order within the question's options
+ 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
+ };
+ }
+
+ ///
+ /// Updates the option text.
+ ///
+ public void UpdateText(string newText)
+ {
+ if (string.IsNullOrWhiteSpace(newText))
+ throw new ArgumentException("Option text cannot be empty", nameof(newText));
+
+ Text = newText;
+ }
+
+ ///
+ /// Updates whether this option is correct.
+ ///
+ public void UpdateIsCorrect(bool newIsCorrect)
+ {
+ IsCorrect = newIsCorrect;
+ }
+
+ ///
+ /// Updates the sort order.
+ ///
+ public void UpdateOrder(int newOrder)
+ {
+ Order = newOrder;
+ }
+}
+
+///
+/// Enum representing the types of quiz questions.
+///
+public enum QuestionType
+{
+ ///
+ /// Multiple choice with single correct answer
+ ///
+ MultipleChoice = 0,
+
+ ///
+ /// Multiple choice with multiple correct answers
+ ///
+ MultipleSelect = 1,
+
+ ///
+ /// True or false question
+ ///
+ TrueFalse = 2,
+
+ ///
+ /// Fill-in-the-blank question
+ ///
+ FillInTheBlank = 3,
+
+ ///
+ /// Matching question
+ ///
+ Matching = 4,
+
+ ///
+ /// Short answer question
+ ///
+ ShortAnswer = 5
+}
diff --git a/GermanApp/Domain/Interfaces/IRepository.cs b/GermanApp/Domain/Interfaces/IRepository.cs
index c085ead..5fe04fc 100644
--- a/GermanApp/Domain/Interfaces/IRepository.cs
+++ b/GermanApp/Domain/Interfaces/IRepository.cs
@@ -133,3 +133,75 @@ public interface IUserProgressRepository : IRepository
///
Task GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
}
+
+///
+/// Repository interface for QuizQuestion entities.
+///
+public interface IQuizQuestionRepository : IRepository
+{
+ ///
+ /// Gets quiz questions by lesson ID.
+ ///
+ Task> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all quiz questions ordered by lesson and question order.
+ ///
+ Task> GetAllOrderedAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets active quiz questions by lesson ID.
+ ///
+ Task> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a random set of quiz questions for a lesson.
+ ///
+ Task> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets quiz questions by difficulty level.
+ ///
+ Task> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets quiz questions by type.
+ ///
+ Task> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks if a quiz question with the given order exists in a lesson.
+ ///
+ Task OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
+}
+
+///
+/// Repository interface for QuizOption entities.
+///
+public interface IQuizOptionRepository : IRepository
+{
+ ///
+ /// Gets quiz options by question ID.
+ ///
+ Task> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the correct option for a question.
+ ///
+ Task GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all correct options for a question (for MultipleSelect type).
+ ///
+ Task> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes all options for a question.
+ ///
+ Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks if the order exists for a question.
+ ///
+ Task OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
+}
diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
index d516e9d..f4023c1 100644
--- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
+++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
@@ -19,6 +19,8 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
public DbSet Users { get; set; } = null!;
public DbSet UserProgress { get; set; } = null!;
public DbSet RefreshTokens { get; set; } = null!;
+ public DbSet QuizQuestions { get; set; } = null!;
+ public DbSet QuizOptions { get; set; } = null!;
// Note: Value objects are not stored directly as entities.
// 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);
});
+ // Configure QuizQuestion entity
+ modelBuilder.Entity(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(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
// In a real application, use migrations or a separate seeding mechanism
// modelBuilder.Entity().HasData(
diff --git a/GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs b/GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs
new file mode 100644
index 0000000..3866582
--- /dev/null
+++ b/GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs
@@ -0,0 +1,390 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("LevelId")
+ .HasColumnType("integer");
+
+ b.Property("Order")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Topic")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsCorrect")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("Order")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("QuizQuestionId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CorrectAnswer")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Difficulty")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(3);
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("LessonId")
+ .HasColumnType("integer");
+
+ b.Property("Order")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("QuestionText")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentLevel")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasDefaultValue("A1");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Streak")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("TotalPoints")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsCompleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("LastAttemptDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LessonId")
+ .HasColumnType("integer");
+
+ b.Property("QuizScore")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("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
+ }
+ }
+}
diff --git a/GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.cs b/GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.cs
new file mode 100644
index 0000000..44b731e
--- /dev/null
+++ b/GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.cs
@@ -0,0 +1,87 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace GermanApp.Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddQuizQuestionTables : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "QuizQuestions",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ LessonId = table.Column(type: "integer", nullable: false),
+ QuestionText = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false),
+ Type = table.Column(type: "integer", nullable: false),
+ CorrectAnswer = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true),
+ Difficulty = table.Column(type: "integer", nullable: false, defaultValue: 3),
+ Order = table.Column(type: "integer", nullable: false, defaultValue: 1),
+ IsActive = table.Column(type: "boolean", nullable: false, defaultValue: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(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(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ QuizQuestionId = table.Column(type: "integer", nullable: false),
+ Text = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false),
+ IsCorrect = table.Column(type: "boolean", nullable: false, defaultValue: false),
+ Order = table.Column(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);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "QuizOptions");
+
+ migrationBuilder.DropTable(
+ name: "QuizQuestions");
+ }
+ }
+}
diff --git a/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs
index e3811d2..3da8976 100644
--- a/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -102,6 +102,92 @@ namespace GermanApp.Migrations
b.ToTable("Levels");
});
+ modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsCorrect")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("Order")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("QuizQuestionId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CorrectAnswer")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Difficulty")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(3);
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("LessonId")
+ .HasColumnType("integer");
+
+ b.Property("Order")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("QuestionText")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
@@ -241,6 +327,28 @@ namespace GermanApp.Migrations
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)
@@ -268,6 +376,11 @@ namespace GermanApp.Migrations
b.Navigation("User");
});
+
+ modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
+ {
+ b.Navigation("Options");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/GermanApp/Infrastructure/Data/Repositories/QuizOptionRepository.cs b/GermanApp/Infrastructure/Data/Repositories/QuizOptionRepository.cs
new file mode 100644
index 0000000..7b569e6
--- /dev/null
+++ b/GermanApp/Infrastructure/Data/Repositories/QuizOptionRepository.cs
@@ -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;
+
+///
+/// Entity Framework Core implementation of IQuizOptionRepository.
+/// This is part of the Infrastructure layer.
+///
+public class QuizOptionRepository : IQuizOptionRepository
+{
+ private readonly AppDbContext _context;
+
+ public QuizOptionRepository(AppDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await _context.QuizOptions
+ .Include(o => o.QuizQuestion)
+ .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
+ }
+
+ public async Task> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ return await _context.QuizOptions
+ .Include(o => o.QuizQuestion)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task 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 ExistsAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await _context.QuizOptions
+ .AnyAsync(o => o.Id == id, cancellationToken);
+ }
+
+ public async Task> 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 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> 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 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);
+ }
+}
diff --git a/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs b/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs
new file mode 100644
index 0000000..96c8928
--- /dev/null
+++ b/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs
@@ -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;
+
+///
+/// Entity Framework Core implementation of IQuizQuestionRepository.
+/// This is part of the Infrastructure layer.
+///
+public class QuizQuestionRepository : IQuizQuestionRepository
+{
+ private readonly AppDbContext _context;
+
+ public QuizQuestionRepository(AppDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task 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> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ return await _context.QuizQuestions
+ .Include(q => q.Lesson)
+ .Include(q => q.Options)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task 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 ExistsAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await _context.QuizQuestions
+ .AnyAsync(q => q.Id == id, cancellationToken);
+ }
+
+ public async Task> 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> 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> 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> 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> 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> 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 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);
+ }
+}
diff --git a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs
index f4c3021..a17ec20 100644
--- a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs
+++ b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs
@@ -66,7 +66,47 @@ public static class SeedDataExtension
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.");
}
}
}
diff --git a/GermanApp/Presentation/Controllers/QuizQuestionsController.cs b/GermanApp/Presentation/Controllers/QuizQuestionsController.cs
new file mode 100644
index 0000000..6c56470
--- /dev/null
+++ b/GermanApp/Presentation/Controllers/QuizQuestionsController.cs
@@ -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;
+
+///
+/// API controller for managing quiz questions.
+/// This is part of the Presentation layer.
+///
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class QuizQuestionsController : ControllerBase
+{
+ private readonly QuizQuestionService _quizQuestionService;
+
+ public QuizQuestionsController(QuizQuestionService quizQuestionService)
+ {
+ _quizQuestionService = quizQuestionService;
+ }
+
+ ///
+ /// Gets all quiz questions.
+ ///
+ /// List of all quiz questions
+ [HttpGet]
+ [AllowAnonymous]
+ public async Task GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
+ return Ok(questions);
+ }
+
+ ///
+ /// Gets all active quiz questions for a specific lesson.
+ ///
+ /// The lesson ID
+ /// List of quiz questions for the specified lesson
+ [HttpGet("by-lesson/{lessonId}")]
+ [AllowAnonymous]
+ public async Task GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
+ return Ok(questions);
+ }
+
+ ///
+ /// Gets a specific quiz question by its ID.
+ ///
+ /// The quiz question ID
+ /// The quiz question with the specified ID
+ [HttpGet("{id}")]
+ [AllowAnonymous]
+ public async Task GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
+ if (question == null)
+ return NotFound();
+ return Ok(question);
+ }
+
+ ///
+ /// Gets a random set of quiz questions for a lesson.
+ ///
+ /// The lesson ID
+ /// Number of questions to return
+ /// List of random quiz questions for the lesson
+ [HttpGet("random/{lessonId}/{count}")]
+ [AllowAnonymous]
+ public async Task GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
+ return Ok(questions);
+ }
+
+ ///
+ /// Gets quiz questions by difficulty level.
+ ///
+ /// The difficulty level (1-5)
+ /// List of quiz questions with the specified difficulty
+ [HttpGet("by-difficulty/{difficulty}")]
+ [AllowAnonymous]
+ public async Task GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
+ return Ok(questions);
+ }
+
+ ///
+ /// Gets quiz questions by type.
+ ///
+ /// The question type
+ /// List of quiz questions with the specified type
+ [HttpGet("by-type/{type}")]
+ [AllowAnonymous]
+ public async Task GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
+ {
+ var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
+ return Ok(questions);
+ }
+
+ ///
+ /// Creates a new quiz question.
+ ///
+ /// The quiz question data
+ /// The created quiz question
+ [HttpPost]
+ [Authorize(Roles = "Admin")]
+ public async Task CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
+ {
+ var question = await _quizQuestionService.CreateQuizQuestionAsync(dto, cancellationToken);
+ return CreatedAtAction(nameof(GetQuizQuestionByIdAsync), new { id = question.Id }, question);
+ }
+
+ ///
+ /// Updates an existing quiz question.
+ ///
+ /// The quiz question ID
+ /// The updated quiz question data
+ /// The updated quiz question
+ [HttpPut("{id}")]
+ [Authorize(Roles = "Admin")]
+ public async Task UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
+ {
+ var question = await _quizQuestionService.UpdateQuizQuestionAsync(id, dto, cancellationToken);
+ if (question == null)
+ return NotFound();
+ return Ok(question);
+ }
+
+ ///
+ /// Deletes a quiz question by its ID.
+ ///
+ /// The quiz question ID
+ /// No content on success
+ [HttpDelete("{id}")]
+ [Authorize(Roles = "Admin")]
+ public async Task DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var result = await _quizQuestionService.DeleteQuizQuestionAsync(id, cancellationToken);
+ if (!result)
+ return NotFound();
+ return NoContent();
+ }
+
+ ///
+ /// Gets quiz options for a specific question.
+ ///
+ /// The question ID
+ /// List of quiz options for the question
+ [HttpGet("options/{questionId}")]
+ [AllowAnonymous]
+ public async Task GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
+ {
+ var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
+ return Ok(options);
+ }
+
+ ///
+ /// Gets the correct option for a question.
+ ///
+ /// The question ID
+ /// The correct option for the question
+ [HttpGet("correct-option/{questionId}")]
+ [AllowAnonymous]
+ public async Task GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
+ {
+ var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
+ if (option == null)
+ return NotFound();
+ return Ok(option);
+ }
+
+ ///
+ /// Gets all correct options for a question (for MultipleSelect type).
+ ///
+ /// The question ID
+ /// List of correct options for the question
+ [HttpGet("correct-options/{questionId}")]
+ [AllowAnonymous]
+ public async Task GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
+ {
+ var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
+ return Ok(options);
+ }
+
+ ///
+ /// Creates a new quiz option for a question.
+ ///
+ /// The question ID
+ /// The quiz option data
+ /// The created quiz option
+ [HttpPost("options/{questionId}")]
+ [Authorize(Roles = "Admin")]
+ public async Task CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
+ {
+ var option = await _quizQuestionService.CreateQuizOptionAsync(questionId, dto, cancellationToken);
+ return CreatedAtAction(nameof(GetQuizOptionsByQuestionAsync), new { questionId }, option);
+ }
+
+ ///
+ /// Updates an existing quiz option.
+ ///
+ /// The quiz option ID
+ /// The updated quiz option data
+ /// The updated quiz option
+ [HttpPut("options/{id}")]
+ [Authorize(Roles = "Admin")]
+ public async Task UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
+ {
+ var option = await _quizQuestionService.UpdateQuizOptionAsync(id, dto, cancellationToken);
+ if (option == null)
+ return NotFound();
+ return Ok(option);
+ }
+
+ ///
+ /// Deletes a quiz option by its ID.
+ ///
+ /// The quiz option ID
+ /// No content on success
+ [HttpDelete("options/{id}")]
+ [Authorize(Roles = "Admin")]
+ public async Task DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var result = await _quizQuestionService.DeleteQuizOptionAsync(id, cancellationToken);
+ if (!result)
+ return NotFound();
+ return NoContent();
+ }
+
+ ///
+ /// Submits quiz answers and returns the result.
+ ///
+ /// The user ID
+ /// The quiz answers submission
+ /// The quiz result
+ [HttpPost("submit/{userId}")]
+ [Authorize]
+ public async Task SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
+ {
+ var result = await _quizQuestionService.SubmitQuizAnswersAsync(userId, dto, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Gets the number of quiz questions for a lesson.
+ ///
+ /// The lesson ID
+ /// The count of quiz questions for the lesson
+ [HttpGet("count/{lessonId}")]
+ [AllowAnonymous]
+ public async Task GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
+ return Ok(new { lessonId, count });
+ }
+
+ ///
+ /// Gets the difficulty distribution for a lesson.
+ ///
+ /// The lesson ID
+ /// The difficulty distribution for the lesson
+ [HttpGet("difficulty-distribution/{lessonId}")]
+ [AllowAnonymous]
+ public async Task GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
+ return Ok(distribution);
+ }
+
+ ///
+ /// Gets the type distribution for a lesson.
+ ///
+ /// The lesson ID
+ /// The type distribution for the lesson
+ [HttpGet("type-distribution/{lessonId}")]
+ [AllowAnonymous]
+ public async Task GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
+ {
+ var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
+ return Ok(distribution);
+ }
+}
diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs
index b1d2a61..7912aba 100644
--- a/GermanApp/Program.cs
+++ b/GermanApp/Program.cs
@@ -134,6 +134,8 @@ try
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
// ============================================
// INFRASTRUCTURE LAYER - AI Services
@@ -169,6 +171,7 @@ try
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped, CreateLessonCommandHandler>();
var app = builder.Build();
@@ -204,10 +207,20 @@ try
app.MapLessonsEndpoints();
// 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("SeedDatabase"));
+
+ if (shouldSeed)
{
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
app.MapGet("/weatherforecast", () =>
@@ -230,6 +243,36 @@ try
.WithName("GetWeatherForecast");
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();
+ 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)
{
diff --git a/Tests/Unit/Application/Services/QuizQuestionServiceTests.cs b/Tests/Unit/Application/Services/QuizQuestionServiceTests.cs
new file mode 100644
index 0000000..a2536f7
--- /dev/null
+++ b/Tests/Unit/Application/Services/QuizQuestionServiceTests.cs
@@ -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 _mockQuizQuestionRepo;
+ private Mock _mockQuizOptionRepo;
+ private Mock _mockLessonRepo;
+ private QuizQuestionService _service;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _mockQuizQuestionRepo = new Mock();
+ _mockQuizOptionRepo = new Mock();
+ _mockLessonRepo = new Mock();
+ _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.Create(1, "Question 1", QuestionType.MultipleChoice, "Answer 1", 3, 1)
+ };
+
+ _mockQuizQuestionRepo.Setup(r => r.GetAllOrderedAsync(It.IsAny()))
+ .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()))
+ .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()))
+ .ReturnsAsync((QuizQuestion?)null);
+
+ var result = await _service.GetQuizQuestionByIdAsync(999);
+
+ Assert.IsNull(result);
+ }
+
+ [TestMethod]
+ public async Task GetQuizQuestionsByLessonAsync_ReturnsFilteredQuestions()
+ {
+ var questions = new List
+ {
+ QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1)
+ };
+
+ _mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny()))
+ .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 { activeQuestion, inactiveQuestion };
+
+ _mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny()))
+ .ReturnsAsync(new List { 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
+ {
+ 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()))
+ .ReturnsAsync(lesson);
+ _mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, null, It.IsAny()))
+ .ReturnsAsync(false);
+ _mockQuizQuestionRepo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(createdQuestion);
+ _mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(createdQuestion.Id, It.IsAny()))
+ .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
+ {
+ 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()))
+ .ReturnsAsync(existing);
+ _mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny()))
+ .ReturnsAsync(lesson);
+ _mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, 1, It.IsAny()))
+ .ReturnsAsync(false);
+ _mockQuizQuestionRepo.Setup(r => r.UpdateAsync(existing, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ _mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny()))
+ .ReturnsAsync(existing);
+ _mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny()))
+ .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()))
+ .ReturnsAsync(question);
+ _mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ _mockQuizQuestionRepo.Setup(r => r.DeleteAsync(question, It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var result = await _service.DeleteQuizQuestionAsync(1);
+
+ Assert.IsTrue(result);
+ }
+
+ [TestMethod]
+ public async Task GetQuizOptionsByQuestionAsync_ReturnsOptions()
+ {
+ var options = new List
+ {
+ QuizOption.Create(1, "Option 1", true, 1),
+ QuizOption.Create(1, "Option 2", false, 2)
+ };
+
+ _mockQuizOptionRepo.Setup(r => r.GetByQuestionAsync(1, It.IsAny()))
+ .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()))
+ .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()))
+ .ReturnsAsync(question);
+ _mockQuizOptionRepo.Setup(r => r.OrderExistsForQuestionAsync(1, 1, null, It.IsAny()))
+ .ReturnsAsync(false);
+ _mockQuizOptionRepo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny()))
+ .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 { question };
+ var answers = new List
+ {
+ new QuizAnswerDto(1, "4", null)
+ };
+ var dto = new SubmitQuizAnswersDto(1, answers);
+
+ _mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny()))
+ .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 { question };
+ var answers = new List
+ {
+ new QuizAnswerDto(1, "5", null) // Wrong answer
+ };
+ var dto = new SubmitQuizAnswersDto(1, answers);
+
+ _mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny()))
+ .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.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()))
+ .ReturnsAsync(questions);
+
+ var result = await _service.GetQuizQuestionCountForLessonAsync(1);
+
+ Assert.AreEqual(2, result);
+ }
+
+ [TestMethod]
+ public async Task GetDifficultyDistributionAsync_ReturnsDistribution()
+ {
+ var questions = new List
+ {
+ 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()))
+ .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
+ }
+
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index cfd84a2..5887fe2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,6 +28,7 @@ services:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: Host=db;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres
+ SeedDatabase: "true"
# JWT Configuration (from Woodpecker secrets or environment)
Jwt__Key: ${JWT_KEY:-your-super-secret-key-at-least-32-characters-long}
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}