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}