feat(backend): Complete Quiz System implementation

- Add Quiz entity with full domain logic (Create, Update, Activate, Deactivate)
- Add QuizQuestion entity updated to use QuizId instead of LessonId
- Add QuizRepository with all CRUD and query methods
- Add QuizQuestionRepository with QuizId-based and LessonId-based (legacy) methods
- Add QuizDto, CreateQuizDto, UpdateQuizDto, QuizWithQuestionsDto, QuizListItemDto
- Add QuizQuestionDto updated with QuizId, LessonId, QuizTitle, Points fields
- Add CreateQuizCommand, UpdateQuizCommand, DeleteQuizCommand, GetQuizWithQuestionsCommand
- Add QuizService with full quiz management (CRUD, activate, deactivate, counts, queries)
- Add QuizQuestionService updated with QuizId-based methods
- Add QuizzesController with comprehensive endpoints (GET, POST, PUT, DELETE)
- Add QuizzesController endpoints for quiz questions (by-quiz, random, active)
- Update QuizQuestionsController with new QuizId-based endpoints (backward compatible)
- Register QuizRepository and QuizService in DI container
- Update IRepository interfaces with QuizId-based methods
- Add SubmitQuizDto for quiz answer submission

Clean Architecture layers maintained:
- Domain: Quiz, QuizQuestion entities with business logic
- Application: DTOs, Commands, Services
- Infrastructure: Repositories, DbContext
- Presentation: Controllers

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-06-13 07:41:46 +02:00
parent 04ad7ef008
commit 242d5ea60b
14 changed files with 1618 additions and 77 deletions

View file

@ -0,0 +1,151 @@
using GermanApp.Domain.Entities;
namespace GermanApp.Application.DTOs;
/// <summary>
/// Data Transfer Object for Quiz - used for API responses.
/// This is a read-only representation of a Quiz entity.
/// </summary>
public record QuizDto(
int Id,
int LessonId,
string LessonTitle,
string Title,
string? Description,
int PassingScore,
int TimeLimitMinutes,
bool ShuffleQuestions,
bool IsActive,
int QuestionCount,
int TotalPoints,
DateTime CreatedAt,
DateTime? UpdatedAt);
/// <summary>
/// Data Transfer Object for creating a new Quiz.
/// </summary>
public record CreateQuizDto(
int LessonId,
string Title,
string? Description,
int PassingScore,
int TimeLimitMinutes,
bool ShuffleQuestions);
/// <summary>
/// Data Transfer Object for updating an existing Quiz.
/// </summary>
public record UpdateQuizDto(
int LessonId,
string Title,
string? Description,
int PassingScore,
int TimeLimitMinutes,
bool ShuffleQuestions,
bool IsActive);
/// <summary>
/// Data Transfer Object for quiz with its questions.
/// </summary>
public record QuizWithQuestionsDto(
int Id,
int LessonId,
string LessonTitle,
string Title,
string? Description,
int PassingScore,
int TimeLimitMinutes,
bool ShuffleQuestions,
bool IsActive,
DateTime CreatedAt,
DateTime? UpdatedAt,
IReadOnlyList<QuizQuestionDto> Questions);
/// <summary>
/// Data Transfer Object for quiz list item (summary).
/// </summary>
public record QuizListItemDto(
int Id,
int LessonId,
string LessonTitle,
string Title,
bool IsActive,
int QuestionCount,
DateTime CreatedAt);
/// <summary>
/// Data Transfer Object for submitting quiz answers.
/// </summary>
public record SubmitQuizDto(
int QuizId,
IReadOnlyList<QuizAnswerDto> Answers);
/// <summary>
/// Extension methods for mapping between Quiz entity and DTOs.
/// </summary>
public static class QuizDtoExtensions
{
public static QuizDto ToDto(this Quiz quiz) => new(
quiz.Id,
quiz.LessonId,
quiz.Lesson?.Title ?? "Unknown",
quiz.Title,
quiz.Description,
quiz.PassingScore,
quiz.TimeLimitMinutes,
quiz.ShuffleQuestions,
quiz.IsActive,
quiz.Questions?.Count ?? 0,
quiz.Questions?.Sum(q => q.Points) ?? 0,
quiz.CreatedAt,
quiz.UpdatedAt);
public static QuizListItemDto ToListItemDto(this Quiz quiz) => new(
quiz.Id,
quiz.LessonId,
quiz.Lesson?.Title ?? "Unknown",
quiz.Title,
quiz.IsActive,
quiz.Questions?.Count ?? 0,
quiz.CreatedAt);
public static QuizWithQuestionsDto ToQuizWithQuestionsDto(this Quiz quiz) => new(
quiz.Id,
quiz.LessonId,
quiz.Lesson?.Title ?? "Unknown",
quiz.Title,
quiz.Description,
quiz.PassingScore,
quiz.TimeLimitMinutes,
quiz.ShuffleQuestions,
quiz.IsActive,
quiz.CreatedAt,
quiz.UpdatedAt,
quiz.Questions?.OrderBy(q => q.Order).Select(q => q.ToDto()).ToList() ?? new List<QuizQuestionDto>());
public static Quiz ToEntity(this CreateQuizDto dto) =>
Quiz.Create(
dto.LessonId,
dto.Title,
dto.Description,
dto.PassingScore,
dto.TimeLimitMinutes);
public static void UpdateFromDto(this Quiz quiz, UpdateQuizDto dto)
{
quiz.UpdateLesson(dto.LessonId);
quiz.UpdateTitle(dto.Title);
quiz.UpdateDescription(dto.Description);
quiz.UpdatePassingScore(dto.PassingScore);
quiz.UpdateTimeLimit(dto.TimeLimitMinutes);
quiz.UpdateShuffleQuestions(dto.ShuffleQuestions);
if (dto.IsActive != quiz.IsActive)
{
if (dto.IsActive)
quiz.Activate();
else
quiz.Deactivate();
}
}
}

View file

@ -8,11 +8,14 @@ namespace GermanApp.Application.DTOs;
/// </summary> /// </summary>
public record QuizQuestionDto( public record QuizQuestionDto(
int Id, int Id,
int QuizId,
int LessonId, int LessonId,
string QuizTitle,
string LessonTitle, string LessonTitle,
string QuestionText, string QuestionText,
QuestionType Type, QuestionType Type,
string? CorrectAnswer, string? CorrectAnswer,
int Points,
int Difficulty, int Difficulty,
int Order, int Order,
bool IsActive, bool IsActive,
@ -24,10 +27,11 @@ public record QuizQuestionDto(
/// Data Transfer Object for creating a new QuizQuestion. /// Data Transfer Object for creating a new QuizQuestion.
/// </summary> /// </summary>
public record CreateQuizQuestionDto( public record CreateQuizQuestionDto(
int LessonId, int QuizId,
string QuestionText, string QuestionText,
QuestionType Type, QuestionType Type,
string? CorrectAnswer, string? CorrectAnswer,
int Points,
int Difficulty, int Difficulty,
int Order, int Order,
IReadOnlyList<CreateQuizOptionDto>? Options); IReadOnlyList<CreateQuizOptionDto>? Options);
@ -36,10 +40,11 @@ public record CreateQuizQuestionDto(
/// Data Transfer Object for updating an existing QuizQuestion. /// Data Transfer Object for updating an existing QuizQuestion.
/// </summary> /// </summary>
public record UpdateQuizQuestionDto( public record UpdateQuizQuestionDto(
int LessonId, int QuizId,
string QuestionText, string QuestionText,
QuestionType Type, QuestionType Type,
string? CorrectAnswer, string? CorrectAnswer,
int Points,
int Difficulty, int Difficulty,
int Order, int Order,
bool IsActive, bool IsActive,
@ -77,9 +82,11 @@ public record UpdateQuizOptionDto(
/// </summary> /// </summary>
public record QuizQuestionWithAnswerDto( public record QuizQuestionWithAnswerDto(
int QuizQuestionId, int QuizQuestionId,
int QuizId,
int LessonId, int LessonId,
string QuestionText, string QuestionText,
QuestionType Type, QuestionType Type,
int Points,
IReadOnlyList<QuizOptionDto> Options, IReadOnlyList<QuizOptionDto> Options,
string? UserAnswer, string? UserAnswer,
bool IsCorrect); bool IsCorrect);
@ -88,7 +95,7 @@ public record QuizQuestionWithAnswerDto(
/// Data Transfer Object for submitting quiz answers. /// Data Transfer Object for submitting quiz answers.
/// </summary> /// </summary>
public record SubmitQuizAnswersDto( public record SubmitQuizAnswersDto(
int LessonId, int QuizId,
IReadOnlyList<QuizAnswerDto> Answers); IReadOnlyList<QuizAnswerDto> Answers);
/// <summary> /// <summary>
@ -103,9 +110,12 @@ public record QuizAnswerDto(
/// Data Transfer Object for quiz result. /// Data Transfer Object for quiz result.
/// </summary> /// </summary>
public record QuizResultDto( public record QuizResultDto(
int QuizId,
int LessonId, int LessonId,
string QuizTitle,
int TotalQuestions, int TotalQuestions,
int CorrectAnswers, int TotalPoints,
int ScorePoints,
double ScorePercentage, double ScorePercentage,
bool Passed, bool Passed,
TimeSpan TimeTaken, TimeSpan TimeTaken,
@ -118,6 +128,7 @@ public record QuizQuestionResultDto(
int QuizQuestionId, int QuizQuestionId,
string QuestionText, string QuestionText,
QuestionType Type, QuestionType Type,
int Points,
bool IsCorrect, bool IsCorrect,
string? CorrectAnswer, string? CorrectAnswer,
string? UserAnswer); string? UserAnswer);
@ -129,11 +140,14 @@ public static class QuizQuestionDtoExtensions
{ {
public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new( public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new(
quizQuestion.Id, quizQuestion.Id,
quizQuestion.LessonId, quizQuestion.QuizId,
quizQuestion.Lesson?.Title ?? "Unknown", quizQuestion.Quiz?.LessonId ?? 0,
quizQuestion.Quiz?.Title ?? "Unknown",
quizQuestion.Quiz?.Lesson?.Title ?? "Unknown",
quizQuestion.QuestionText, quizQuestion.QuestionText,
quizQuestion.Type, quizQuestion.Type,
quizQuestion.CorrectAnswer, quizQuestion.CorrectAnswer,
quizQuestion.Points,
quizQuestion.Difficulty, quizQuestion.Difficulty,
quizQuestion.Order, quizQuestion.Order,
quizQuestion.IsActive, quizQuestion.IsActive,
@ -143,19 +157,21 @@ public static class QuizQuestionDtoExtensions
public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) => public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) =>
QuizQuestion.Create( QuizQuestion.Create(
dto.LessonId, dto.QuizId,
dto.QuestionText, dto.QuestionText,
dto.Type, dto.Type,
dto.CorrectAnswer ?? string.Empty, dto.CorrectAnswer ?? string.Empty,
dto.Points,
dto.Difficulty, dto.Difficulty,
dto.Order); dto.Order);
public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto) public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto)
{ {
quizQuestion.UpdateLesson(dto.LessonId); quizQuestion.UpdateQuiz(dto.QuizId);
quizQuestion.UpdateQuestionText(dto.QuestionText); quizQuestion.UpdateQuestionText(dto.QuestionText);
quizQuestion.UpdateType(dto.Type); quizQuestion.UpdateType(dto.Type);
quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty); quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty);
quizQuestion.UpdatePoints(dto.Points);
quizQuestion.UpdateDifficulty(dto.Difficulty); quizQuestion.UpdateDifficulty(dto.Difficulty);
quizQuestion.UpdateOrder(dto.Order); quizQuestion.UpdateOrder(dto.Order);

View file

@ -1,5 +1,6 @@
using GermanApp.Application.DTOs; using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities; using GermanApp.Domain.Entities;
using GermanApp.Domain.Exceptions;
using GermanApp.Domain.Interfaces; using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Services; namespace GermanApp.Application.Services;
@ -12,15 +13,18 @@ public class QuizQuestionService
{ {
private readonly IQuizQuestionRepository _quizQuestionRepository; private readonly IQuizQuestionRepository _quizQuestionRepository;
private readonly IQuizOptionRepository _quizOptionRepository; private readonly IQuizOptionRepository _quizOptionRepository;
private readonly IQuizRepository _quizRepository;
private readonly ILessonRepository _lessonRepository; private readonly ILessonRepository _lessonRepository;
public QuizQuestionService( public QuizQuestionService(
IQuizQuestionRepository quizQuestionRepository, IQuizQuestionRepository quizQuestionRepository,
IQuizOptionRepository quizOptionRepository, IQuizOptionRepository quizOptionRepository,
IQuizRepository quizRepository,
ILessonRepository lessonRepository) ILessonRepository lessonRepository)
{ {
_quizQuestionRepository = quizQuestionRepository; _quizQuestionRepository = quizQuestionRepository;
_quizOptionRepository = quizOptionRepository; _quizOptionRepository = quizOptionRepository;
_quizRepository = quizRepository;
_lessonRepository = lessonRepository; _lessonRepository = lessonRepository;
} }
@ -92,13 +96,13 @@ public class QuizQuestionService
/// </summary> /// </summary>
public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default) public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
{ {
// Validate that the lesson exists // Validate that the quiz exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken); var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
if (lesson == null) if (quiz == null)
throw new ArgumentException("Lesson does not exist"); throw new NotFoundException("Quiz does not exist");
// Validate question order uniqueness within the lesson // Validate question order uniqueness within the quiz
await ValidateQuestionOrderUniquenessAsync(0, dto.LessonId, dto.Order, cancellationToken); await ValidateQuestionOrderUniquenessAsync(0, dto.QuizId, dto.Order, cancellationToken);
// Create the question // Create the question
var question = dto.ToEntity(); var question = dto.ToEntity();
@ -128,13 +132,13 @@ public class QuizQuestionService
if (question == null) if (question == null)
return null; return null;
// Validate that the lesson exists // Validate that the quiz exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken); var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
if (lesson == null) if (quiz == null)
throw new ArgumentException("Lesson does not exist"); throw new NotFoundException("Quiz does not exist");
// Validate question order uniqueness within the lesson // Validate question order uniqueness within the quiz
await ValidateQuestionOrderUniquenessAsync(id, dto.LessonId, dto.Order, cancellationToken); await ValidateQuestionOrderUniquenessAsync(id, dto.QuizId, dto.Order, cancellationToken);
// Update the question // Update the question
question.UpdateFromDto(dto); question.UpdateFromDto(dto);
@ -160,13 +164,13 @@ public class QuizQuestionService
} }
/// <summary> /// <summary>
/// Validates that the question order is unique within its lesson. /// Validates that the question order is unique within its quiz.
/// </summary> /// </summary>
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int lessonId, int order, CancellationToken cancellationToken) private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int quizId, int order, CancellationToken cancellationToken)
{ {
var exists = await _quizQuestionRepository.OrderExistsInLessonAsync(lessonId, order, excludeQuestionId, cancellationToken); var exists = await _quizQuestionRepository.OrderExistsInQuizAsync(quizId, order, excludeQuestionId, cancellationToken);
if (exists) if (exists)
throw new ArgumentException("A quiz question with this order already exists in the specified lesson"); throw new ValidationException("A quiz question with this order already exists in the specified quiz");
} }
/// <summary> /// <summary>
@ -277,18 +281,21 @@ public class QuizQuestionService
} }
/// <summary> /// <summary>
/// Submits quiz answers and returns the result. /// Submits quiz answers for a specific quiz and returns the result.
/// </summary> /// </summary>
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default) public virtual async Task<QuizResultDto> SubmitQuizAnswersForQuizAsync(int userId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
{ {
var questions = await _quizQuestionRepository.GetByLessonAsync(dto.LessonId, cancellationToken); var quiz = await _quizRepository.GetWithQuestionsAsync(dto.QuizId, cancellationToken);
var questionDictionary = questions.ToDictionary(q => q.Id); if (quiz == null)
throw new NotFoundException("Quiz does not exist");
var questions = quiz.Questions?.Where(q => q.IsActive).OrderBy(q => q.Order).ToList() ?? new List<QuizQuestion>();
var totalQuestions = questions.Count; var totalQuestions = questions.Count;
var correctAnswers = 0; var totalPoints = questions.Sum(q => q.Points);
var scorePoints = 0;
var questionResults = new List<QuizQuestionResultDto>(); var questionResults = new List<QuizQuestionResultDto>();
// Get user's answers // Get user's answers (using QuizAnswerDto which has the same fields as SubmitAnswerDto)
var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId); var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId);
foreach (var question in questions) foreach (var question in questions)
@ -299,7 +306,7 @@ public class QuizQuestionService
if (userAnswers.TryGetValue(question.Id, out var userAnswer)) if (userAnswers.TryGetValue(question.Id, out var userAnswer))
{ {
userAnswerText = userAnswer?.AnswerText ?? string.Empty; userAnswerText = userAnswer.AnswerText ?? string.Empty;
switch (question.Type) switch (question.Type)
{ {
@ -341,7 +348,7 @@ public class QuizQuestionService
// Case-insensitive comparison with trimming // Case-insensitive comparison with trimming
if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer)) if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer))
{ {
isCorrect = userAnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase); isCorrect = userAnswerText.Trim().Equals(correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase);
} }
break; break;
@ -353,30 +360,47 @@ public class QuizQuestionService
} }
if (isCorrect) if (isCorrect)
correctAnswers++; scorePoints += question.Points;
questionResults.Add(new QuizQuestionResultDto( questionResults.Add(new QuizQuestionResultDto(
question.Id, question.Id,
question.QuestionText, question.QuestionText,
question.Type, question.Type,
question.Points,
isCorrect, isCorrect,
correctAnswer, correctAnswer,
userAnswerText)); userAnswerText));
} }
var scorePercentage = totalQuestions > 0 ? (double)correctAnswers / totalQuestions * 100 : 0; var scorePercentage = totalPoints > 0 ? (double)scorePoints / totalPoints * 100 : 0;
var passed = scorePercentage >= 80; // Passing score is 80% var passed = quiz.IsPassed(scorePercentage);
return new QuizResultDto( return new QuizResultDto(
dto.LessonId, quiz.Id,
quiz.LessonId,
quiz.Title,
totalQuestions, totalQuestions,
correctAnswers, totalPoints,
scorePoints,
scorePercentage, scorePercentage,
passed, passed,
TimeSpan.Zero, // Would be calculated based on actual time taken TimeSpan.Zero, // Would be calculated based on actual time taken
questionResults); questionResults);
} }
/// <summary>
/// Submits quiz answers and returns the result (legacy - converts SubmitQuizAnswersDto to SubmitQuizDto).
/// </summary>
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
{
// Convert legacy DTO to new format
// SubmitQuizAnswersDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
// SubmitQuizDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
// They're the same, so just create a new SubmitQuizDto
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
return await SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
}
/// <summary> /// <summary>
/// Gets the number of quiz questions for a lesson. /// Gets the number of quiz questions for a lesson.
/// </summary> /// </summary>
@ -417,4 +441,43 @@ public class QuizQuestionService
return distribution; return distribution;
} }
// ============================================
// Quiz-based methods (QuizId)
// ============================================
/// <summary>
/// Gets quiz questions by quiz ID.
/// </summary>
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionRepository.GetByQuizAsync(quizId, cancellationToken);
return questions.Select(q => q.ToDto()).ToList();
}
/// <summary>
/// Gets active quiz questions by quiz ID.
/// </summary>
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionRepository.GetActiveByQuizAsync(quizId, cancellationToken);
return questions.Select(q => q.ToDto()).ToList();
}
/// <summary>
/// Gets a random set of quiz questions for a quiz.
/// </summary>
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionRepository.GetRandomQuestionsForQuizAsync(quizId, count, cancellationToken);
return questions.Select(q => q.ToDto()).ToList();
}
/// <summary>
/// Gets the total points for a quiz.
/// </summary>
public virtual async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
return await _quizQuestionRepository.GetTotalPointsForQuizAsync(quizId, cancellationToken);
}
} }

View file

@ -0,0 +1,273 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Exceptions;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for managing quizzes.
/// This is part of the Application layer.
/// </summary>
public class QuizService
{
private readonly IQuizRepository _quizRepository;
private readonly IQuizQuestionRepository _quizQuestionRepository;
private readonly IQuizOptionRepository _quizOptionRepository;
private readonly ILessonRepository _lessonRepository;
public QuizService(
IQuizRepository quizRepository,
IQuizQuestionRepository quizQuestionRepository,
IQuizOptionRepository quizOptionRepository,
ILessonRepository lessonRepository)
{
_quizRepository = quizRepository;
_quizQuestionRepository = quizQuestionRepository;
_quizOptionRepository = quizOptionRepository;
_lessonRepository = lessonRepository;
}
/// <summary>
/// Gets all quizzes.
/// </summary>
public virtual async Task<IReadOnlyList<QuizDto>> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken);
return quizzes.Select(q => q.ToDto()).ToList();
}
/// <summary>
/// Gets all quizzes as list items (summary).
/// </summary>
public virtual async Task<IReadOnlyList<QuizListItemDto>> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken);
return quizzes.Select(q => q.ToListItemDto()).ToList();
}
/// <summary>
/// Gets a quiz by its ID.
/// </summary>
public virtual async Task<QuizDto?> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
return quiz?.ToDto();
}
/// <summary>
/// Gets a quiz with its questions by ID.
/// </summary>
public virtual async Task<QuizWithQuestionsDto?> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetWithQuestionsAsync(id, cancellationToken);
return quiz?.ToQuizWithQuestionsDto();
}
/// <summary>
/// Gets quizzes by lesson ID.
/// </summary>
public virtual async Task<IReadOnlyList<QuizDto>> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken);
return quizzes.Select(q => q.ToDto()).ToList();
}
/// <summary>
/// Gets active quizzes by lesson ID.
/// </summary>
public virtual async Task<IReadOnlyList<QuizDto>> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
return quizzes.Select(q => q.ToDto()).ToList();
}
/// <summary>
/// Gets the first quiz for a lesson.
/// </summary>
public virtual async Task<QuizDto?> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetFirstByLessonAsync(lessonId, cancellationToken);
return quiz?.ToDto();
}
/// <summary>
/// Creates a new quiz.
/// </summary>
public virtual async Task<QuizDto> CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default)
{
// Validate that the lesson exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
if (lesson == null)
throw new NotFoundException("Lesson does not exist");
// Validate business rules
if (dto.PassingScore < 0 || dto.PassingScore > 100)
{
throw new ValidationException("Passing score must be between 0 and 100");
}
if (dto.TimeLimitMinutes < 0)
{
throw new ValidationException("Time limit cannot be negative");
}
// Convert DTO to domain entity
var quiz = dto.ToEntity();
// Add to repository
var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken);
// Return DTO representation
return createdQuiz.ToDto();
}
/// <summary>
/// Updates an existing quiz.
/// </summary>
public virtual async Task<QuizDto?> UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
if (quiz == null)
return null;
// Validate that the lesson exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
if (lesson == null)
throw new NotFoundException("Lesson does not exist");
// Validate business rules
if (dto.PassingScore < 0 || dto.PassingScore > 100)
{
throw new ValidationException("Passing score must be between 0 and 100");
}
if (dto.TimeLimitMinutes < 0)
{
throw new ValidationException("Time limit cannot be negative");
}
// Update the quiz
quiz.UpdateFromDto(dto);
await _quizRepository.UpdateAsync(quiz, cancellationToken);
// Reload and return
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
return updatedQuiz?.ToDto();
}
/// <summary>
/// Deletes a quiz by its ID.
/// </summary>
public virtual async Task<bool> DeleteQuizAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
if (quiz == null)
return false;
// First, delete all options for all questions in this quiz
var questions = await _quizQuestionRepository.GetByQuizAsync(id, cancellationToken);
foreach (var question in questions)
{
await _quizOptionRepository.DeleteByQuestionAsync(question.Id, cancellationToken);
}
// Then delete all questions for this quiz
foreach (var question in questions)
{
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
}
// Finally, delete the quiz itself
await _quizRepository.DeleteAsync(quiz, cancellationToken);
return true;
}
/// <summary>
/// Checks if a quiz exists for a lesson.
/// </summary>
public virtual async Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _quizRepository.ExistsByLessonAsync(lessonId, cancellationToken);
}
/// <summary>
/// Checks if a quiz exists by ID.
/// </summary>
public virtual async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _quizRepository.ExistsAsync(id, cancellationToken);
}
/// <summary>
/// Gets the total number of quizzes.
/// </summary>
public virtual async Task<int> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
return quizzes.Count;
}
/// <summary>
/// Gets the total number of quizzes for a lesson.
/// </summary>
public virtual async Task<int> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken);
return quizzes.Count;
}
/// <summary>
/// Gets quizzes by passing score range.
/// </summary>
public virtual async Task<IReadOnlyList<QuizDto>> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
return quizzes.Where(q => q.PassingScore >= minScore && q.PassingScore <= maxScore)
.Select(q => q.ToDto())
.ToList();
}
/// <summary>
/// Gets active quizzes.
/// </summary>
public virtual async Task<IReadOnlyList<QuizDto>> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
return quizzes.Where(q => q.IsActive)
.Select(q => q.ToDto())
.ToList();
}
/// <summary>
/// Activates a quiz.
/// </summary>
public virtual async Task<QuizDto?> ActivateQuizAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
if (quiz == null)
return null;
quiz.Activate();
await _quizRepository.UpdateAsync(quiz, cancellationToken);
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
return updatedQuiz?.ToDto();
}
/// <summary>
/// Deactivates a quiz.
/// </summary>
public virtual async Task<QuizDto?> DeactivateQuizAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
if (quiz == null)
return null;
quiz.Deactivate();
await _quizRepository.UpdateAsync(quiz, cancellationToken);
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
return updatedQuiz?.ToDto();
}
}

View file

@ -0,0 +1,162 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Exceptions;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.UseCases.Commands;
/// <summary>
/// Command for creating a new quiz.
/// This follows the CQRS pattern - commands represent write operations.
/// </summary>
public record CreateQuizCommand(CreateQuizDto QuizData) : ICommand<QuizDto>;
/// <summary>
/// Handler for the CreateQuizCommand.
/// </summary>
public class CreateQuizCommandHandler : ICommandHandler<CreateQuizCommand, QuizDto>
{
private readonly IQuizRepository _quizRepository;
private readonly ILessonRepository _lessonRepository;
public CreateQuizCommandHandler(
IQuizRepository quizRepository,
ILessonRepository lessonRepository)
{
_quizRepository = quizRepository;
_lessonRepository = lessonRepository;
}
public async Task<QuizDto> Handle(CreateQuizCommand command, CancellationToken cancellationToken)
{
// Validate that the lesson exists
var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken);
if (lesson == null)
throw new NotFoundException("Lesson does not exist");
// Validate business rules
if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100)
{
throw new ValidationException("Passing score must be between 0 and 100");
}
if (command.QuizData.TimeLimitMinutes < 0)
{
throw new ValidationException("Time limit cannot be negative");
}
// Convert DTO to domain entity
var quiz = command.QuizData.ToEntity();
// Add to repository
var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken);
// Return DTO representation
return createdQuiz.ToDto();
}
}
/// <summary>
/// Command for updating an existing quiz.
/// </summary>
public record UpdateQuizCommand(int Id, UpdateQuizDto QuizData) : ICommand<QuizDto?>;
/// <summary>
/// Handler for the UpdateQuizCommand.
/// </summary>
public class UpdateQuizCommandHandler : ICommandHandler<UpdateQuizCommand, QuizDto?>
{
private readonly IQuizRepository _quizRepository;
private readonly ILessonRepository _lessonRepository;
public UpdateQuizCommandHandler(
IQuizRepository quizRepository,
ILessonRepository lessonRepository)
{
_quizRepository = quizRepository;
_lessonRepository = lessonRepository;
}
public async Task<QuizDto?> Handle(UpdateQuizCommand command, CancellationToken cancellationToken)
{
var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
if (quiz == null)
return null;
// Validate that the lesson exists
var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken);
if (lesson == null)
throw new NotFoundException("Lesson does not exist");
// Validate business rules
if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100)
{
throw new ValidationException("Passing score must be between 0 and 100");
}
if (command.QuizData.TimeLimitMinutes < 0)
{
throw new ValidationException("Time limit cannot be negative");
}
// Update the quiz
quiz.UpdateFromDto(command.QuizData);
await _quizRepository.UpdateAsync(quiz, cancellationToken);
// Reload and return
var updatedQuiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
return updatedQuiz?.ToDto();
}
}
/// <summary>
/// Command for deleting a quiz.
/// </summary>
public record DeleteQuizCommand(int Id) : ICommand<bool>;
/// <summary>
/// Handler for the DeleteQuizCommand.
/// </summary>
public class DeleteQuizCommandHandler : ICommandHandler<DeleteQuizCommand, bool>
{
private readonly IQuizRepository _quizRepository;
public DeleteQuizCommandHandler(IQuizRepository quizRepository)
{
_quizRepository = quizRepository;
}
public async Task<bool> Handle(DeleteQuizCommand command, CancellationToken cancellationToken)
{
var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
if (quiz == null)
return false;
await _quizRepository.DeleteAsync(quiz, cancellationToken);
return true;
}
}
/// <summary>
/// Command for getting a quiz with its questions.
/// </summary>
public record GetQuizWithQuestionsCommand(int Id) : ICommand<QuizWithQuestionsDto?>;
/// <summary>
/// Handler for the GetQuizWithQuestionsCommand.
/// </summary>
public class GetQuizWithQuestionsCommandHandler : ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>
{
private readonly IQuizRepository _quizRepository;
public GetQuizWithQuestionsCommandHandler(IQuizRepository quizRepository)
{
_quizRepository = quizRepository;
}
public async Task<QuizWithQuestionsDto?> Handle(GetQuizWithQuestionsCommand command, CancellationToken cancellationToken)
{
var quiz = await _quizRepository.GetWithQuestionsAsync(command.Id, cancellationToken);
return quiz?.ToQuizWithQuestionsDto();
}
}

View file

@ -0,0 +1,182 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a quiz that can be associated with a lesson.
/// A quiz contains multiple questions and has a passing score threshold.
/// </summary>
public class Quiz
{
public int Id { get; private set; }
public int LessonId { get; private set; }
public string Title { get; private set; } = string.Empty;
public string? Description { get; private set; }
public int PassingScore { get; private set; } = 80; // Default 80%
public int TimeLimitMinutes { get; private set; } = 0; // 0 = no limit
public bool IsActive { get; private set; } = true;
public bool ShuffleQuestions { 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<QuizQuestion> Questions { get; private set; } = new List<QuizQuestion>();
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private Quiz() { }
/// <summary>
/// Factory method to create a new quiz.
/// </summary>
/// <param name="lessonId">ID of the associated lesson</param>
/// <param name="title">Title of the quiz</param>
/// <param name="description">Optional description</param>
/// <param name="passingScore">Passing score percentage (default 80)</param>
/// <param name="timeLimitMinutes">Time limit in minutes (0 for no limit)</param>
public static Quiz Create(
int lessonId,
string title,
string? description = null,
int passingScore = 80,
int timeLimitMinutes = 0)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Quiz title cannot be empty", nameof(title));
if (passingScore < 0 || passingScore > 100)
throw new ArgumentOutOfRangeException(nameof(passingScore), "Passing score must be between 0 and 100");
if (timeLimitMinutes < 0)
throw new ArgumentOutOfRangeException(nameof(timeLimitMinutes), "Time limit cannot be negative");
return new Quiz
{
LessonId = lessonId,
Title = title,
Description = description,
PassingScore = passingScore,
TimeLimitMinutes = timeLimitMinutes,
CreatedAt = DateTime.UtcNow
};
}
/// <summary>
/// Updates the quiz title.
/// </summary>
public void UpdateTitle(string newTitle)
{
if (string.IsNullOrWhiteSpace(newTitle))
throw new ArgumentException("Quiz title cannot be empty", nameof(newTitle));
Title = newTitle;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the quiz description.
/// </summary>
public void UpdateDescription(string? newDescription)
{
Description = newDescription;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the passing score.
/// </summary>
public void UpdatePassingScore(int newPassingScore)
{
if (newPassingScore < 0 || newPassingScore > 100)
throw new ArgumentOutOfRangeException(nameof(newPassingScore), "Passing score must be between 0 and 100");
PassingScore = newPassingScore;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the time limit.
/// </summary>
public void UpdateTimeLimit(int newTimeLimitMinutes)
{
if (newTimeLimitMinutes < 0)
throw new ArgumentOutOfRangeException(nameof(newTimeLimitMinutes), "Time limit cannot be negative");
TimeLimitMinutes = newTimeLimitMinutes;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the associated lesson.
/// </summary>
public void UpdateLesson(int newLessonId)
{
LessonId = newLessonId;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the shuffle questions setting.
/// </summary>
public void UpdateShuffleQuestions(bool newShuffleQuestions)
{
ShuffleQuestions = newShuffleQuestions;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Activates the quiz.
/// </summary>
public void Activate()
{
IsActive = true;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Deactivates the quiz.
/// </summary>
public void Deactivate()
{
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Adds a question to this quiz.
/// </summary>
public void AddQuestion(QuizQuestion question)
{
if (question == null)
throw new ArgumentNullException(nameof(question));
Questions.Add(question);
}
/// <summary>
/// Removes a question from this quiz.
/// </summary>
public void RemoveQuestion(QuizQuestion question)
{
if (question == null)
throw new ArgumentNullException(nameof(question));
Questions.Remove(question);
}
/// <summary>
/// Checks if the quiz is passed based on a score percentage.
/// </summary>
public bool IsPassed(double scorePercentage) => scorePercentage >= PassingScore;
/// <summary>
/// Calculates the score percentage based on correct answers.
/// </summary>
public double CalculateScore(int correctAnswers, int totalQuestions)
{
if (totalQuestions == 0)
return 0;
return (double)correctAnswers / totalQuestions * 100;
}
}

View file

@ -1,17 +1,18 @@
namespace GermanApp.Domain.Entities; namespace GermanApp.Domain.Entities;
/// <summary> /// <summary>
/// Represents a quiz question for a lesson. /// Represents a quiz question for a quiz.
/// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.) /// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.)
/// and is associated with a specific lesson. /// and is associated with a specific quiz.
/// </summary> /// </summary>
public class QuizQuestion public class QuizQuestion
{ {
public int Id { get; private set; } public int Id { get; private set; }
public int LessonId { get; private set; } public int QuizId { get; private set; }
public string QuestionText { get; private set; } = string.Empty; public string QuestionText { get; private set; } = string.Empty;
public QuestionType Type { get; private set; } public QuestionType Type { get; private set; }
public string? CorrectAnswer { get; private set; } public string? CorrectAnswer { get; private set; }
public int Points { get; private set; } = 1; // Points for this question
public int Difficulty { get; private set; } // 1-5 scale public int Difficulty { get; private set; } // 1-5 scale
public int Order { get; private set; } public int Order { get; private set; }
public bool IsActive { get; private set; } = true; public bool IsActive { get; private set; } = true;
@ -19,7 +20,7 @@ public class QuizQuestion
public DateTime? UpdatedAt { get; private set; } public DateTime? UpdatedAt { get; private set; }
// Navigation property // Navigation property
public virtual Lesson? Lesson { get; private set; } public virtual Quiz? Quiz { get; private set; }
public virtual ICollection<QuizOption> Options { get; private set; } = new List<QuizOption>(); public virtual ICollection<QuizOption> Options { get; private set; } = new List<QuizOption>();
/// <summary> /// <summary>
@ -30,32 +31,38 @@ public class QuizQuestion
/// <summary> /// <summary>
/// Factory method to create a new quiz question. /// Factory method to create a new quiz question.
/// </summary> /// </summary>
/// <param name="lessonId">ID of the parent lesson</param> /// <param name="quizId">ID of the parent quiz</param>
/// <param name="questionText">The question text</param> /// <param name="questionText">The question text</param>
/// <param name="type">The question type</param> /// <param name="type">The question type</param>
/// <param name="correctAnswer">The correct answer</param> /// <param name="correctAnswer">The correct answer</param>
/// <param name="difficulty">Difficulty level (1-5)</param> /// <param name="points">Points for this question (default 1)</param>
/// <param name="order">Sort order within the lesson</param> /// <param name="difficulty">Difficulty level (1-5, default 3)</param>
/// <param name="order">Sort order within the quiz</param>
public static QuizQuestion Create( public static QuizQuestion Create(
int lessonId, int quizId,
string questionText, string questionText,
QuestionType type, QuestionType type,
string correctAnswer, string correctAnswer,
int points = 1,
int difficulty = 3, int difficulty = 3,
int order = 1) int order = 1)
{ {
if (difficulty < 1 || difficulty > 5) if (difficulty < 1 || difficulty > 5)
throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5"); throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5");
if (points < 0)
throw new ArgumentOutOfRangeException(nameof(points), "Points must be positive");
if (string.IsNullOrWhiteSpace(questionText)) if (string.IsNullOrWhiteSpace(questionText))
throw new ArgumentException("Question text cannot be empty", nameof(questionText)); throw new ArgumentException("Question text cannot be empty", nameof(questionText));
return new QuizQuestion return new QuizQuestion
{ {
LessonId = lessonId, QuizId = quizId,
QuestionText = questionText, QuestionText = questionText,
Type = type, Type = type,
CorrectAnswer = correctAnswer, CorrectAnswer = correctAnswer,
Points = points,
Difficulty = difficulty, Difficulty = difficulty,
Order = order, Order = order,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@ -83,6 +90,18 @@ public class QuizQuestion
UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow;
} }
/// <summary>
/// Updates the points for this question.
/// </summary>
public void UpdatePoints(int newPoints)
{
if (newPoints < 0)
throw new ArgumentOutOfRangeException(nameof(newPoints), "Points must be positive");
Points = newPoints;
UpdatedAt = DateTime.UtcNow;
}
/// <summary> /// <summary>
/// Updates the difficulty level. /// Updates the difficulty level.
/// </summary> /// </summary>
@ -114,11 +133,11 @@ public class QuizQuestion
} }
/// <summary> /// <summary>
/// Updates the associated lesson. /// Updates the associated quiz.
/// </summary> /// </summary>
public void UpdateLesson(int newLessonId) public void UpdateQuiz(int newQuizId)
{ {
LessonId = newLessonId; QuizId = newQuizId;
UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow;
} }

View file

@ -173,6 +173,76 @@ public interface IQuizQuestionRepository : IRepository<QuizQuestion, int>
/// Checks if a quiz question with the given order exists in a lesson. /// Checks if a quiz question with the given order exists in a lesson.
/// </summary> /// </summary>
Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default); Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
// ============================================
// Quiz-based methods (QuizId)
// ============================================
/// <summary>
/// Gets quiz questions by quiz ID.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets active quiz questions by quiz ID.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets quiz questions by quiz with options loaded.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a random set of quiz questions for a quiz.
/// </summary>
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the total points for a quiz.
/// </summary>
Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a quiz question with the given order exists in a quiz.
/// </summary>
Task<bool> OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for Quiz entities.
/// </summary>
public interface IQuizRepository : IRepository<Quiz, int>
{
/// <summary>
/// Gets quizzes by lesson ID.
/// </summary>
Task<IReadOnlyList<Quiz>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all quizzes ordered by lesson and quiz order.
/// </summary>
Task<IReadOnlyList<Quiz>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets active quizzes by lesson ID.
/// </summary>
Task<IReadOnlyList<Quiz>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a quiz with its questions and options.
/// </summary>
Task<Quiz?> GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the first quiz for a lesson.
/// </summary>
Task<Quiz?> GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a quiz exists for a lesson.
/// </summary>
Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
} }
/// <summary> /// <summary>

View file

@ -19,6 +19,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
public DbSet<User> Users { get; set; } = null!; public DbSet<User> Users { get; set; } = null!;
public DbSet<UserProgress> UserProgress { get; set; } = null!; public DbSet<UserProgress> UserProgress { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!; public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
public DbSet<Quiz> Quizzes { get; set; } = null!;
public DbSet<QuizQuestion> QuizQuestions { get; set; } = null!; public DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
public DbSet<QuizOption> QuizOptions { get; set; } = null!; public DbSet<QuizOption> QuizOptions { get; set; } = null!;
@ -134,17 +135,17 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// Configure QuizQuestion entity // Configure Quiz entity
modelBuilder.Entity<QuizQuestion>(builder => modelBuilder.Entity<Quiz>(builder =>
{ {
builder.HasKey(q => q.Id); builder.HasKey(q => q.Id);
builder.Property(q => q.LessonId).IsRequired(); builder.Property(q => q.LessonId).IsRequired();
builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000); builder.Property(q => q.Title).IsRequired().HasMaxLength(200);
builder.Property(q => q.Type).IsRequired(); builder.Property(q => q.Description).HasMaxLength(2000);
builder.Property(q => q.CorrectAnswer).HasMaxLength(1000); builder.Property(q => q.PassingScore).IsRequired().HasDefaultValue(80);
builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3); builder.Property(q => q.TimeLimitMinutes).HasDefaultValue(0);
builder.Property(q => q.Order).IsRequired().HasDefaultValue(1);
builder.Property(q => q.IsActive).HasDefaultValue(true); builder.Property(q => q.IsActive).HasDefaultValue(true);
builder.Property(q => q.ShuffleQuestions).HasDefaultValue(true);
builder.Property(q => q.CreatedAt).IsRequired(); builder.Property(q => q.CreatedAt).IsRequired();
builder.Property(q => q.UpdatedAt).IsRequired(false); builder.Property(q => q.UpdatedAt).IsRequired(false);
@ -154,8 +155,36 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
.HasForeignKey(q => q.LessonId) .HasForeignKey(q => q.LessonId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one question per lesson per order // Navigation to questions
builder.HasIndex(q => new { q.LessonId, q.Order }).IsUnique(); builder.HasMany(q => q.Questions)
.WithOne(qq => qq.Quiz)
.HasForeignKey(qq => qq.QuizId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure QuizQuestion entity
modelBuilder.Entity<QuizQuestion>(builder =>
{
builder.HasKey(q => q.Id);
builder.Property(q => q.QuizId).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.Points).IsRequired().HasDefaultValue(1);
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 Quiz
builder.HasOne(q => q.Quiz)
.WithMany(qq => qq.Questions)
.HasForeignKey(q => q.QuizId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one question per quiz per order
builder.HasIndex(q => new { q.QuizId, q.Order }).IsUnique();
// Navigation to options // Navigation to options
builder.HasMany(q => q.Options) builder.HasMany(q => q.Options)

View file

@ -21,7 +21,8 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<QuizQuestion?> GetByIdAsync(int id, CancellationToken cancellationToken = default) public async Task<QuizQuestion?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order)) .Include(q => q.Options.OrderBy(o => o.Order))
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken); .FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
} }
@ -29,7 +30,8 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options) .Include(q => q.Options)
.AsNoTracking() .AsNoTracking()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@ -63,9 +65,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order)) .Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.LessonId == lessonId) .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId)
.OrderBy(q => q.Order) .OrderBy(q => q.Order)
.AsNoTracking() .AsNoTracking()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@ -74,9 +77,11 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order)) .Include(q => q.Options.OrderBy(o => o.Order))
.OrderBy(q => q.Lesson.Order) .OrderBy(q => q.Quiz.Lesson.Order)
.ThenBy(q => q.Quiz.Id)
.ThenBy(q => q.Order) .ThenBy(q => q.Order)
.AsNoTracking() .AsNoTracking()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@ -85,9 +90,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order)) .Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.LessonId == lessonId && q.IsActive) .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.IsActive)
.OrderBy(q => q.Order) .OrderBy(q => q.Order)
.AsNoTracking() .AsNoTracking()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@ -96,9 +102,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order)) .Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.LessonId == lessonId && q.IsActive) .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.IsActive)
.OrderBy(q => Guid.NewGuid()) // Random ordering .OrderBy(q => Guid.NewGuid()) // Random ordering
.Take(count) .Take(count)
.AsNoTracking() .AsNoTracking()
@ -108,7 +115,7 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.Include(q => q.Options) .Include(q => q.Options)
.Where(q => q.Difficulty == difficulty) .Where(q => q.Difficulty == difficulty)
.OrderBy(q => q.Order) .OrderBy(q => q.Order)
@ -119,7 +126,7 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
{ {
return await _context.QuizQuestions return await _context.QuizQuestions
.Include(q => q.Lesson) .Include(q => q.Quiz)
.Include(q => q.Options) .Include(q => q.Options)
.Where(q => q.Type == type) .Where(q => q.Type == type)
.OrderBy(q => q.Order) .OrderBy(q => q.Order)
@ -130,7 +137,79 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default) public async Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
{ {
var query = _context.QuizQuestions var query = _context.QuizQuestions
.Where(q => q.LessonId == lessonId && q.Order == order); .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.Order == order);
if (excludeId.HasValue)
{
query = query.Where(q => q.Id != excludeId.Value);
}
return await query.AnyAsync(cancellationToken);
}
// ============================================
// Quiz-based methods (QuizId)
// ============================================
public async Task<IReadOnlyList<QuizQuestion>> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Quiz)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.QuizId == quizId)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Quiz)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.QuizId == quizId && q.IsActive)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Quiz)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.QuizId == quizId)
.OrderBy(q => q.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Quiz)
.Include(q => q.Options.OrderBy(o => o.Order))
.Where(q => q.QuizId == quizId && q.IsActive)
.OrderBy(q => Guid.NewGuid()) // Random ordering
.Take(count)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var questions = await _context.QuizQuestions
.Where(q => q.QuizId == quizId && q.IsActive)
.Select(q => q.Points)
.ToListAsync(cancellationToken);
return questions.Sum();
}
public async Task<bool> OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
{
var query = _context.QuizQuestions
.Where(q => q.QuizId == quizId && q.Order == order);
if (excludeId.HasValue) if (excludeId.HasValue)
{ {

View file

@ -0,0 +1,114 @@
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.Repositories;
/// <summary>
/// Entity Framework Core implementation of IQuizRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class QuizRepository : IQuizRepository
{
private readonly AppDbContext _context;
public QuizRepository(AppDbContext context)
{
_context = context;
}
public async Task<Quiz?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Quiz>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<Quiz> AddAsync(Quiz entity, CancellationToken cancellationToken = default)
{
await _context.Quizzes.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(Quiz entity, CancellationToken cancellationToken = default)
{
_context.Quizzes.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(Quiz entity, CancellationToken cancellationToken = default)
{
_context.Quizzes.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.AnyAsync(q => q.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Quiz>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.Where(q => q.LessonId == lessonId)
.OrderBy(q => q.Id)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Quiz>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.OrderBy(q => q.Lesson.Order)
.ThenBy(q => q.Id)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Quiz>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.Where(q => q.LessonId == lessonId && q.IsActive)
.OrderBy(q => q.Id)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<Quiz?> GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.Include(q => q.Questions.OrderBy(qq => qq.Order))
.ThenInclude(qq => qq.Options.OrderBy(o => o.Order))
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
}
public async Task<Quiz?> GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.Include(q => q.Lesson)
.Where(q => q.LessonId == lessonId && q.IsActive)
.OrderBy(q => q.Id)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.Quizzes
.AnyAsync(q => q.LessonId == lessonId, cancellationToken);
}
}

View file

@ -35,7 +35,20 @@ public class QuizQuestionsController : ControllerBase
} }
/// <summary> /// <summary>
/// Gets all active quiz questions for a specific lesson. /// Gets all active quiz questions for a specific quiz.
/// </summary>
/// <param name="quizId">The quiz ID</param>
/// <returns>List of quiz questions for the specified quiz</returns>
[HttpGet("by-quiz/{quizId}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets all active quiz questions for a specific lesson (legacy - uses first quiz).
/// </summary> /// </summary>
/// <param name="lessonId">The lesson ID</param> /// <param name="lessonId">The lesson ID</param>
/// <returns>List of quiz questions for the specified lesson</returns> /// <returns>List of quiz questions for the specified lesson</returns>
@ -63,12 +76,26 @@ public class QuizQuestionsController : ControllerBase
} }
/// <summary> /// <summary>
/// Gets a random set of quiz questions for a lesson. /// Gets a random set of quiz questions for a quiz.
/// </summary>
/// <param name="quizId">The quiz ID</param>
/// <param name="count">Number of questions to return</param>
/// <returns>List of random quiz questions for the quiz</returns>
[HttpGet("random/{quizId}/{count}")]
[AllowAnonymous]
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets a random set of quiz questions for a lesson (legacy - uses first quiz).
/// </summary> /// </summary>
/// <param name="lessonId">The lesson ID</param> /// <param name="lessonId">The lesson ID</param>
/// <param name="count">Number of questions to return</param> /// <param name="count">Number of questions to return</param>
/// <returns>List of random quiz questions for the lesson</returns> /// <returns>List of random quiz questions for the lesson</returns>
[HttpGet("random/{lessonId}/{count}")] [HttpGet("random/by-lesson/{lessonId}/{count}")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default) public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
{ {
@ -105,7 +132,7 @@ public class QuizQuestionsController : ControllerBase
/// <summary> /// <summary>
/// Creates a new quiz question. /// Creates a new quiz question.
/// </summary> /// </summary>
/// <param name="dto">The quiz question data</param> /// <param name="dto">The quiz question data (requires QuizId)</param>
/// <returns>The created quiz question</returns> /// <returns>The created quiz question</returns>
[HttpPost] [HttpPost]
[Authorize(Roles = "Admin")] [Authorize(Roles = "Admin")]
@ -236,13 +263,15 @@ public class QuizQuestionsController : ControllerBase
/// Submits quiz answers and returns the result. /// Submits quiz answers and returns the result.
/// </summary> /// </summary>
/// <param name="userId">The user ID</param> /// <param name="userId">The user ID</param>
/// <param name="dto">The quiz answers submission</param> /// <param name="dto">The quiz answers submission (requires QuizId)</param>
/// <returns>The quiz result</returns> /// <returns>The quiz result</returns>
[HttpPost("submit/{userId}")] [HttpPost("submit/{userId}")]
[Authorize] [Authorize]
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default) public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
{ {
var result = await _quizQuestionService.SubmitQuizAnswersAsync(userId, dto, cancellationToken); // Convert legacy DTO to new format
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
return Ok(result); return Ok(result);
} }

View file

@ -0,0 +1,344 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace GermanApp.Presentation.Controllers;
/// <summary>
/// API controller for managing quizzes.
/// This is part of the Presentation layer.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class QuizzesController : ControllerBase
{
private readonly QuizService _quizService;
private readonly QuizQuestionService _quizQuestionService;
public QuizzesController(
QuizService quizService,
QuizQuestionService quizQuestionService)
{
_quizService = quizService;
_quizQuestionService = quizQuestionService;
}
/// <summary>
/// Gets all quizzes.
/// </summary>
/// <returns>List of all quizzes</returns>
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
return Ok(quizzes);
}
/// <summary>
/// Gets all quizzes as list items (summary).
/// </summary>
/// <returns>List of quiz summary items</returns>
[HttpGet("list")]
[AllowAnonymous]
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
return Ok(quizzes);
}
/// <summary>
/// Gets a specific quiz by its ID.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <returns>The quiz with the specified ID</returns>
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
if (quiz == null)
return NotFound();
return Ok(quiz);
}
/// <summary>
/// Gets a quiz with its questions by ID.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <returns>The quiz with questions</returns>
[HttpGet("{id}/with-questions")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
if (quiz == null)
return NotFound();
return Ok(quiz);
}
/// <summary>
/// Gets all quizzes for a specific lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>List of quizzes for the specified lesson</returns>
[HttpGet("by-lesson/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
return Ok(quizzes);
}
/// <summary>
/// Gets active quizzes for a specific lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>List of active quizzes for the specified lesson</returns>
[HttpGet("active/by-lesson/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
return Ok(quizzes);
}
/// <summary>
/// Gets the first quiz for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>The first quiz for the lesson</returns>
[HttpGet("first/by-lesson/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
if (quiz == null)
return NotFound();
return Ok(quiz);
}
/// <summary>
/// Gets all active quizzes.
/// </summary>
/// <returns>List of active quizzes</returns>
[HttpGet("active")]
[AllowAnonymous]
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
{
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
return Ok(quizzes);
}
/// <summary>
/// Creates a new quiz.
/// </summary>
/// <param name="dto">The quiz data</param>
/// <returns>The created quiz</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.CreateQuizAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetQuizByIdAsync), new { id = quiz.Id }, quiz);
}
/// <summary>
/// Updates an existing quiz.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <param name="dto">The updated quiz data</param>
/// <returns>The updated quiz</returns>
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.UpdateQuizAsync(id, dto, cancellationToken);
if (quiz == null)
return NotFound();
return Ok(quiz);
}
/// <summary>
/// Deletes a quiz by its ID.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteQuizAsync(int id, CancellationToken cancellationToken = default)
{
var result = await _quizService.DeleteQuizAsync(id, cancellationToken);
if (!result)
return NotFound();
return NoContent();
}
/// <summary>
/// Activates a quiz.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <returns>The activated quiz</returns>
[HttpPost("{id}/activate")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> ActivateQuizAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.ActivateQuizAsync(id, cancellationToken);
if (quiz == null)
return NotFound();
return Ok(quiz);
}
/// <summary>
/// Deactivates a quiz.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <returns>The deactivated quiz</returns>
[HttpPost("{id}/deactivate")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeactivateQuizAsync(int id, CancellationToken cancellationToken = default)
{
var quiz = await _quizService.DeactivateQuizAsync(id, cancellationToken);
if (quiz == null)
return NotFound();
return Ok(quiz);
}
/// <summary>
/// Checks if a quiz exists by ID.
/// </summary>
/// <param name="id">The quiz ID</param>
/// <returns>True if the quiz exists</returns>
[HttpGet("exists/{id}")]
[AllowAnonymous]
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
var exists = await _quizService.ExistsAsync(id, cancellationToken);
return Ok(new { exists });
}
/// <summary>
/// Checks if a quiz exists for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>True if a quiz exists for the lesson</returns>
[HttpGet("exists/by-lesson/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
return Ok(new { lessonId, exists });
}
/// <summary>
/// Gets the total number of quizzes.
/// </summary>
/// <returns>The total count of quizzes</returns>
[HttpGet("count")]
[AllowAnonymous]
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
{
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
return Ok(new { count });
}
/// <summary>
/// Gets the number of quizzes for a lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <returns>The count of quizzes for the lesson</returns>
[HttpGet("count/by-lesson/{lessonId}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
return Ok(new { lessonId, count });
}
/// <summary>
/// Gets quizzes by passing score range.
/// </summary>
/// <param name="minScore">Minimum passing score</param>
/// <param name="maxScore">Maximum passing score</param>
/// <returns>List of quizzes in the score range</returns>
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
{
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
return Ok(quizzes);
}
/// <summary>
/// Gets quiz questions for a specific quiz.
/// </summary>
/// <param name="quizId">The quiz ID</param>
/// <returns>List of quiz questions for the quiz</returns>
[HttpGet("{quizId}/questions")]
[AllowAnonymous]
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets active quiz questions for a specific quiz.
/// </summary>
/// <param name="quizId">The quiz ID</param>
/// <returns>List of active quiz questions for the quiz</returns>
[HttpGet("{quizId}/questions/active")]
[AllowAnonymous]
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Gets random quiz questions for a quiz.
/// </summary>
/// <param name="quizId">The quiz ID</param>
/// <param name="count">Number of questions to return</param>
/// <returns>List of random quiz questions</returns>
[HttpGet("{quizId}/questions/random/{count}")]
[AllowAnonymous]
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
{
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
return Ok(questions);
}
/// <summary>
/// Submits quiz answers and returns the result.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="quizId">The quiz ID</param>
/// <param name="dto">The quiz answers submission</param>
/// <returns>The quiz result</returns>
[HttpPost("submit/{userId}/{quizId}")]
[Authorize]
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, int quizId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
{
// Update the submission to use the correct quizId
var submission = new SubmitQuizDto(quizId, dto.Answers);
var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, submission, cancellationToken);
return Ok(result);
}
/// <summary>
/// Gets the total points for a quiz.
/// </summary>
/// <param name="quizId">The quiz ID</param>
/// <returns>The total points for the quiz</returns>
[HttpGet("{quizId}/total-points")]
[AllowAnonymous]
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
{
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
return Ok(new { quizId, points });
}
}

View file

@ -134,6 +134,7 @@ try
builder.Services.AddScoped<ILevelRepository, LevelRepository>(); builder.Services.AddScoped<ILevelRepository, LevelRepository>();
builder.Services.AddScoped<ILessonRepository, LessonRepository>(); builder.Services.AddScoped<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>(); builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
builder.Services.AddScoped<IQuizRepository, QuizRepository>();
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>(); builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>(); builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
@ -171,8 +172,17 @@ try
builder.Services.AddScoped<LevelService>(); builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>(); builder.Services.AddScoped<LessonService>();
builder.Services.AddScoped<ProgressService>(); builder.Services.AddScoped<ProgressService>();
builder.Services.AddScoped<QuizService>();
builder.Services.AddScoped<QuizQuestionService>(); builder.Services.AddScoped<QuizQuestionService>();
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>(); builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
// Note: QuizQuestionService requires IQuizRepository which is registered above
// Register Quiz command handlers
builder.Services.AddScoped<ICommandHandler<CreateQuizCommand, QuizDto>, CreateQuizCommandHandler>();
builder.Services.AddScoped<ICommandHandler<UpdateQuizCommand, QuizDto?>, UpdateQuizCommandHandler>();
builder.Services.AddScoped<ICommandHandler<DeleteQuizCommand, bool>, DeleteQuizCommandHandler>();
builder.Services.AddScoped<ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>, GetQuizWithQuestionsCommandHandler>();
var app = builder.Build(); var app = builder.Build();