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

View file

@ -1,5 +1,6 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Exceptions;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Services;
@ -12,15 +13,18 @@ public class QuizQuestionService
{
private readonly IQuizQuestionRepository _quizQuestionRepository;
private readonly IQuizOptionRepository _quizOptionRepository;
private readonly IQuizRepository _quizRepository;
private readonly ILessonRepository _lessonRepository;
public QuizQuestionService(
IQuizQuestionRepository quizQuestionRepository,
IQuizOptionRepository quizOptionRepository,
IQuizRepository quizRepository,
ILessonRepository lessonRepository)
{
_quizQuestionRepository = quizQuestionRepository;
_quizOptionRepository = quizOptionRepository;
_quizRepository = quizRepository;
_lessonRepository = lessonRepository;
}
@ -92,13 +96,13 @@ public class QuizQuestionService
/// </summary>
public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
{
// Validate that the lesson exists
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
if (lesson == null)
throw new ArgumentException("Lesson does not exist");
// Validate that the quiz exists
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
if (quiz == null)
throw new NotFoundException("Quiz does not exist");
// Validate question order uniqueness within the lesson
await ValidateQuestionOrderUniquenessAsync(0, dto.LessonId, dto.Order, cancellationToken);
// Validate question order uniqueness within the quiz
await ValidateQuestionOrderUniquenessAsync(0, dto.QuizId, dto.Order, cancellationToken);
// Create the question
var question = dto.ToEntity();
@ -128,13 +132,13 @@ public class QuizQuestionService
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 that the quiz exists
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
if (quiz == null)
throw new NotFoundException("Quiz does not exist");
// Validate question order uniqueness within the lesson
await ValidateQuestionOrderUniquenessAsync(id, dto.LessonId, dto.Order, cancellationToken);
// Validate question order uniqueness within the quiz
await ValidateQuestionOrderUniquenessAsync(id, dto.QuizId, dto.Order, cancellationToken);
// Update the question
question.UpdateFromDto(dto);
@ -160,13 +164,13 @@ public class QuizQuestionService
}
/// <summary>
/// Validates that the question order is unique within its lesson.
/// Validates that the question order is unique within its quiz.
/// </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)
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>
@ -277,18 +281,21 @@ public class QuizQuestionService
}
/// <summary>
/// Submits quiz answers and returns the result.
/// Submits quiz answers for a specific quiz and returns the result.
/// </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 questionDictionary = questions.ToDictionary(q => q.Id);
var quiz = await _quizRepository.GetWithQuestionsAsync(dto.QuizId, cancellationToken);
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 correctAnswers = 0;
var totalPoints = questions.Sum(q => q.Points);
var scorePoints = 0;
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);
foreach (var question in questions)
@ -299,7 +306,7 @@ public class QuizQuestionService
if (userAnswers.TryGetValue(question.Id, out var userAnswer))
{
userAnswerText = userAnswer?.AnswerText ?? string.Empty;
userAnswerText = userAnswer.AnswerText ?? string.Empty;
switch (question.Type)
{
@ -341,7 +348,7 @@ public class QuizQuestionService
// Case-insensitive comparison with trimming
if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer))
{
isCorrect = userAnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
isCorrect = userAnswerText.Trim().Equals(correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase);
}
break;
@ -353,30 +360,47 @@ public class QuizQuestionService
}
if (isCorrect)
correctAnswers++;
scorePoints += question.Points;
questionResults.Add(new QuizQuestionResultDto(
question.Id,
question.QuestionText,
question.Type,
question.Points,
isCorrect,
correctAnswer,
userAnswerText));
}
var scorePercentage = totalQuestions > 0 ? (double)correctAnswers / totalQuestions * 100 : 0;
var passed = scorePercentage >= 80; // Passing score is 80%
var scorePercentage = totalPoints > 0 ? (double)scorePoints / totalPoints * 100 : 0;
var passed = quiz.IsPassed(scorePercentage);
return new QuizResultDto(
dto.LessonId,
quiz.Id,
quiz.LessonId,
quiz.Title,
totalQuestions,
correctAnswers,
totalPoints,
scorePoints,
scorePercentage,
passed,
TimeSpan.Zero, // Would be calculated based on actual time taken
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>
/// Gets the number of quiz questions for a lesson.
/// </summary>
@ -417,4 +441,43 @@ public class QuizQuestionService
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;
/// <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.)
/// and is associated with a specific lesson.
/// and is associated with a specific quiz.
/// </summary>
public class QuizQuestion
{
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 QuestionType Type { 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 Order { get; private set; }
public bool IsActive { get; private set; } = true;
@ -19,7 +20,7 @@ public class QuizQuestion
public DateTime? UpdatedAt { get; private set; }
// 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>();
/// <summary>
@ -30,32 +31,38 @@ public class QuizQuestion
/// <summary>
/// Factory method to create a new quiz question.
/// </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="type">The question type</param>
/// <param name="correctAnswer">The correct answer</param>
/// <param name="difficulty">Difficulty level (1-5)</param>
/// <param name="order">Sort order within the lesson</param>
/// <param name="points">Points for this question (default 1)</param>
/// <param name="difficulty">Difficulty level (1-5, default 3)</param>
/// <param name="order">Sort order within the quiz</param>
public static QuizQuestion Create(
int lessonId,
int quizId,
string questionText,
QuestionType type,
string correctAnswer,
int points = 1,
int difficulty = 3,
int order = 1)
{
if (difficulty < 1 || difficulty > 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))
throw new ArgumentException("Question text cannot be empty", nameof(questionText));
return new QuizQuestion
{
LessonId = lessonId,
QuizId = quizId,
QuestionText = questionText,
Type = type,
CorrectAnswer = correctAnswer,
Points = points,
Difficulty = difficulty,
Order = order,
CreatedAt = DateTime.UtcNow
@ -83,6 +90,18 @@ public class QuizQuestion
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>
/// Updates the difficulty level.
/// </summary>
@ -114,11 +133,11 @@ public class QuizQuestion
}
/// <summary>
/// Updates the associated lesson.
/// Updates the associated quiz.
/// </summary>
public void UpdateLesson(int newLessonId)
public void UpdateQuiz(int newQuizId)
{
LessonId = newLessonId;
QuizId = newQuizId;
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.
/// </summary>
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>

View file

@ -19,6 +19,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserProgress> UserProgress { 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<QuizOption> QuizOptions { get; set; } = null!;
@ -134,17 +135,17 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// Configure QuizQuestion entity
modelBuilder.Entity<QuizQuestion>(builder =>
// Configure Quiz entity
modelBuilder.Entity<Quiz>(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.Title).IsRequired().HasMaxLength(200);
builder.Property(q => q.Description).HasMaxLength(2000);
builder.Property(q => q.PassingScore).IsRequired().HasDefaultValue(80);
builder.Property(q => q.TimeLimitMinutes).HasDefaultValue(0);
builder.Property(q => q.IsActive).HasDefaultValue(true);
builder.Property(q => q.ShuffleQuestions).HasDefaultValue(true);
builder.Property(q => q.CreatedAt).IsRequired();
builder.Property(q => q.UpdatedAt).IsRequired(false);
@ -154,8 +155,36 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
.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 questions
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
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)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options.OrderBy(o => o.Order))
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
}
@ -29,7 +30,8 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.Include(q => q.Options)
.AsNoTracking()
.ToListAsync(cancellationToken);
@ -63,9 +65,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.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)
.AsNoTracking()
.ToListAsync(cancellationToken);
@ -74,9 +77,11 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.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)
.AsNoTracking()
.ToListAsync(cancellationToken);
@ -85,9 +90,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.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)
.AsNoTracking()
.ToListAsync(cancellationToken);
@ -96,9 +102,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.ThenInclude(q => q.Lesson)
.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
.Take(count)
.AsNoTracking()
@ -108,7 +115,7 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.Include(q => q.Options)
.Where(q => q.Difficulty == difficulty)
.OrderBy(q => q.Order)
@ -119,7 +126,7 @@ public class QuizQuestionRepository : IQuizQuestionRepository
public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
{
return await _context.QuizQuestions
.Include(q => q.Lesson)
.Include(q => q.Quiz)
.Include(q => q.Options)
.Where(q => q.Type == type)
.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)
{
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)
{

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>
/// 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>
/// <param name="lessonId">The lesson ID</param>
/// <returns>List of quiz questions for the specified lesson</returns>
@ -63,12 +76,26 @@ public class QuizQuestionsController : ControllerBase
}
/// <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>
/// <param name="lessonId">The lesson ID</param>
/// <param name="count">Number of questions to return</param>
/// <returns>List of random quiz questions for the lesson</returns>
[HttpGet("random/{lessonId}/{count}")]
[HttpGet("random/by-lesson/{lessonId}/{count}")]
[AllowAnonymous]
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
{
@ -105,7 +132,7 @@ public class QuizQuestionsController : ControllerBase
/// <summary>
/// Creates a new quiz question.
/// </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>
[HttpPost]
[Authorize(Roles = "Admin")]
@ -236,13 +263,15 @@ public class QuizQuestionsController : ControllerBase
/// Submits quiz answers and returns the result.
/// </summary>
/// <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>
[HttpPost("submit/{userId}")]
[Authorize]
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);
}

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<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
builder.Services.AddScoped<IQuizRepository, QuizRepository>();
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
@ -171,8 +172,17 @@ try
builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>();
builder.Services.AddScoped<ProgressService>();
builder.Services.AddScoped<QuizService>();
builder.Services.AddScoped<QuizQuestionService>();
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();