feat(backend/domain): add quiz question feature with Docker migration support
- Add QuizQuestion and QuizOption domain entities with QuestionType enum - Add IQuizQuestionRepository and IQuizOptionRepository interfaces - Add QuizQuestionRepository and QuizOptionRepository EF Core implementations - Add QuizQuestionService with CRUD, quiz submission, and statistics - Add QuizQuestionsController with comprehensive REST API endpoints - Add QuizQuestionDto and related DTOs for API communication - Add EF Core migration (20260612050652_AddQuizQuestionTables) for QuizQuestion and QuizOption - Add seed data with sample quiz questions for Greetings, Numbers, Grammar lessons - Update AppDbContext with DbSets and entity configurations - Update Program.cs with migration retry logic for Docker - Update docker-compose.yml with SeedDatabase configuration - Add unit tests for QuizQuestionService Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
c8c5f7431d
commit
04ad7ef008
15 changed files with 2538 additions and 2 deletions
191
GermanApp/Application/DTOs/QuizQuestionDto.cs
Normal file
191
GermanApp/Application/DTOs/QuizQuestionDto.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
using GermanApp.Domain.Entities;
|
||||
|
||||
namespace GermanApp.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for QuizQuestion - used for API responses.
|
||||
/// This is a read-only representation of a QuizQuestion entity.
|
||||
/// </summary>
|
||||
public record QuizQuestionDto(
|
||||
int Id,
|
||||
int LessonId,
|
||||
string LessonTitle,
|
||||
string QuestionText,
|
||||
QuestionType Type,
|
||||
string? CorrectAnswer,
|
||||
int Difficulty,
|
||||
int Order,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
IReadOnlyList<QuizOptionDto> Options);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for creating a new QuizQuestion.
|
||||
/// </summary>
|
||||
public record CreateQuizQuestionDto(
|
||||
int LessonId,
|
||||
string QuestionText,
|
||||
QuestionType Type,
|
||||
string? CorrectAnswer,
|
||||
int Difficulty,
|
||||
int Order,
|
||||
IReadOnlyList<CreateQuizOptionDto>? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for updating an existing QuizQuestion.
|
||||
/// </summary>
|
||||
public record UpdateQuizQuestionDto(
|
||||
int LessonId,
|
||||
string QuestionText,
|
||||
QuestionType Type,
|
||||
string? CorrectAnswer,
|
||||
int Difficulty,
|
||||
int Order,
|
||||
bool IsActive,
|
||||
IReadOnlyList<UpdateQuizOptionDto>? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for QuizOption - used for API responses.
|
||||
/// </summary>
|
||||
public record QuizOptionDto(
|
||||
int Id,
|
||||
int QuizQuestionId,
|
||||
string Text,
|
||||
bool IsCorrect,
|
||||
int Order);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for creating a new QuizOption.
|
||||
/// </summary>
|
||||
public record CreateQuizOptionDto(
|
||||
string Text,
|
||||
bool IsCorrect,
|
||||
int Order);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for updating an existing QuizOption.
|
||||
/// </summary>
|
||||
public record UpdateQuizOptionDto(
|
||||
int Id,
|
||||
string Text,
|
||||
bool IsCorrect,
|
||||
int Order);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for quiz question with user's answer.
|
||||
/// </summary>
|
||||
public record QuizQuestionWithAnswerDto(
|
||||
int QuizQuestionId,
|
||||
int LessonId,
|
||||
string QuestionText,
|
||||
QuestionType Type,
|
||||
IReadOnlyList<QuizOptionDto> Options,
|
||||
string? UserAnswer,
|
||||
bool IsCorrect);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for submitting quiz answers.
|
||||
/// </summary>
|
||||
public record SubmitQuizAnswersDto(
|
||||
int LessonId,
|
||||
IReadOnlyList<QuizAnswerDto> Answers);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for a single quiz answer submission.
|
||||
/// </summary>
|
||||
public record QuizAnswerDto(
|
||||
int QuizQuestionId,
|
||||
string? AnswerText,
|
||||
int[]? SelectedOptionIds);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for quiz result.
|
||||
/// </summary>
|
||||
public record QuizResultDto(
|
||||
int LessonId,
|
||||
int TotalQuestions,
|
||||
int CorrectAnswers,
|
||||
double ScorePercentage,
|
||||
bool Passed,
|
||||
TimeSpan TimeTaken,
|
||||
IReadOnlyList<QuizQuestionResultDto> QuestionResults);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for individual question result.
|
||||
/// </summary>
|
||||
public record QuizQuestionResultDto(
|
||||
int QuizQuestionId,
|
||||
string QuestionText,
|
||||
QuestionType Type,
|
||||
bool IsCorrect,
|
||||
string? CorrectAnswer,
|
||||
string? UserAnswer);
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping between QuizQuestion entity and DTOs.
|
||||
/// </summary>
|
||||
public static class QuizQuestionDtoExtensions
|
||||
{
|
||||
public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new(
|
||||
quizQuestion.Id,
|
||||
quizQuestion.LessonId,
|
||||
quizQuestion.Lesson?.Title ?? "Unknown",
|
||||
quizQuestion.QuestionText,
|
||||
quizQuestion.Type,
|
||||
quizQuestion.CorrectAnswer,
|
||||
quizQuestion.Difficulty,
|
||||
quizQuestion.Order,
|
||||
quizQuestion.IsActive,
|
||||
quizQuestion.CreatedAt,
|
||||
quizQuestion.UpdatedAt,
|
||||
quizQuestion.Options?.OrderBy(o => o.Order).Select(o => o.ToDto()).ToList() ?? new List<QuizOptionDto>());
|
||||
|
||||
public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) =>
|
||||
QuizQuestion.Create(
|
||||
dto.LessonId,
|
||||
dto.QuestionText,
|
||||
dto.Type,
|
||||
dto.CorrectAnswer ?? string.Empty,
|
||||
dto.Difficulty,
|
||||
dto.Order);
|
||||
|
||||
public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto)
|
||||
{
|
||||
quizQuestion.UpdateLesson(dto.LessonId);
|
||||
quizQuestion.UpdateQuestionText(dto.QuestionText);
|
||||
quizQuestion.UpdateType(dto.Type);
|
||||
quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty);
|
||||
quizQuestion.UpdateDifficulty(dto.Difficulty);
|
||||
quizQuestion.UpdateOrder(dto.Order);
|
||||
|
||||
if (dto.IsActive != quizQuestion.IsActive)
|
||||
{
|
||||
if (dto.IsActive)
|
||||
quizQuestion.Activate();
|
||||
else
|
||||
quizQuestion.Deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
public static QuizOptionDto ToDto(this QuizOption quizOption) => new(
|
||||
quizOption.Id,
|
||||
quizOption.QuizQuestionId,
|
||||
quizOption.Text,
|
||||
quizOption.IsCorrect,
|
||||
quizOption.Order);
|
||||
|
||||
public static QuizOption ToEntity(this CreateQuizOptionDto dto, int quizQuestionId) =>
|
||||
QuizOption.Create(
|
||||
quizQuestionId,
|
||||
dto.Text,
|
||||
dto.IsCorrect,
|
||||
dto.Order);
|
||||
|
||||
public static void UpdateFromDto(this QuizOption quizOption, UpdateQuizOptionDto dto)
|
||||
{
|
||||
quizOption.UpdateText(dto.Text);
|
||||
quizOption.UpdateIsCorrect(dto.IsCorrect);
|
||||
quizOption.UpdateOrder(dto.Order);
|
||||
}
|
||||
}
|
||||
420
GermanApp/Application/Services/QuizQuestionService.cs
Normal file
420
GermanApp/Application/Services/QuizQuestionService.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
|
||||
namespace GermanApp.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application service for managing quiz questions.
|
||||
/// This is part of the Application layer.
|
||||
/// </summary>
|
||||
public class QuizQuestionService
|
||||
{
|
||||
private readonly IQuizQuestionRepository _quizQuestionRepository;
|
||||
private readonly IQuizOptionRepository _quizOptionRepository;
|
||||
private readonly ILessonRepository _lessonRepository;
|
||||
|
||||
public QuizQuestionService(
|
||||
IQuizQuestionRepository quizQuestionRepository,
|
||||
IQuizOptionRepository quizOptionRepository,
|
||||
ILessonRepository lessonRepository)
|
||||
{
|
||||
_quizQuestionRepository = quizQuestionRepository;
|
||||
_quizOptionRepository = quizOptionRepository;
|
||||
_lessonRepository = lessonRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all quiz questions.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetAllOrderedAsync(cancellationToken);
|
||||
return questions.Select(q => q.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a quiz question by its ID.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizQuestionDto?> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
||||
return question?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by lesson ID.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetByLessonAsync(lessonId, cancellationToken);
|
||||
return questions.Select(q => q.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets active quiz questions by lesson ID.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
||||
return questions.Select(q => q.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a random set of quiz questions for a lesson.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetRandomQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
||||
return questions.Select(q => q.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by difficulty level.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetByDifficultyAsync(difficulty, cancellationToken);
|
||||
return questions.Select(q => q.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by type.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetByTypeAsync(type, cancellationToken);
|
||||
return questions.Select(q => q.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new quiz question with its options.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate that the lesson exists
|
||||
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
||||
if (lesson == null)
|
||||
throw new ArgumentException("Lesson does not exist");
|
||||
|
||||
// Validate question order uniqueness within the lesson
|
||||
await ValidateQuestionOrderUniquenessAsync(0, dto.LessonId, dto.Order, cancellationToken);
|
||||
|
||||
// Create the question
|
||||
var question = dto.ToEntity();
|
||||
var createdQuestion = await _quizQuestionRepository.AddAsync(question, cancellationToken);
|
||||
|
||||
// Create options if provided
|
||||
if (dto.Options != null && dto.Options.Any())
|
||||
{
|
||||
foreach (var optionDto in dto.Options)
|
||||
{
|
||||
var option = optionDto.ToEntity(createdQuestion.Id);
|
||||
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the question with options
|
||||
var questionWithOptions = await _quizQuestionRepository.GetByIdAsync(createdQuestion.Id, cancellationToken);
|
||||
return questionWithOptions?.ToDto() ?? createdQuestion.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing quiz question and its options.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizQuestionDto?> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (question == null)
|
||||
return null;
|
||||
|
||||
// Validate that the lesson exists
|
||||
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
||||
if (lesson == null)
|
||||
throw new ArgumentException("Lesson does not exist");
|
||||
|
||||
// Validate question order uniqueness within the lesson
|
||||
await ValidateQuestionOrderUniquenessAsync(id, dto.LessonId, dto.Order, cancellationToken);
|
||||
|
||||
// Update the question
|
||||
question.UpdateFromDto(dto);
|
||||
await _quizQuestionRepository.UpdateAsync(question, cancellationToken);
|
||||
|
||||
// Update options if provided
|
||||
if (dto.Options != null)
|
||||
{
|
||||
// Delete existing options
|
||||
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
||||
|
||||
// Add new options
|
||||
foreach (var optionDto in dto.Options)
|
||||
{
|
||||
var option = new CreateQuizOptionDto(optionDto.Text, optionDto.IsCorrect, optionDto.Order).ToEntity(id);
|
||||
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the question with updated options
|
||||
var updatedQuestion = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
||||
return updatedQuestion?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the question order is unique within its lesson.
|
||||
/// </summary>
|
||||
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int lessonId, int order, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await _quizQuestionRepository.OrderExistsInLessonAsync(lessonId, order, excludeQuestionId, cancellationToken);
|
||||
if (exists)
|
||||
throw new ArgumentException("A quiz question with this order already exists in the specified lesson");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a quiz question and its options by its ID.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (question == null)
|
||||
return false;
|
||||
|
||||
// Delete all options for this question
|
||||
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
||||
|
||||
// Delete the question
|
||||
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz options for a specific question.
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _quizOptionRepository.GetByQuestionAsync(questionId, cancellationToken);
|
||||
return options.Select(o => o.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correct option for a question.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizOptionDto?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizOptionRepository.GetCorrectOptionAsync(questionId, cancellationToken);
|
||||
return option?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all correct options for a question (for MultipleSelect type).
|
||||
/// </summary>
|
||||
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _quizOptionRepository.GetCorrectOptionsAsync(questionId, cancellationToken);
|
||||
return options.Select(o => o.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a quiz option for a question.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizOptionDto> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate that the question exists
|
||||
var question = await _quizQuestionRepository.GetByIdAsync(questionId, cancellationToken);
|
||||
if (question == null)
|
||||
throw new ArgumentException("Quiz question does not exist");
|
||||
|
||||
// Validate option order uniqueness within the question
|
||||
await ValidateOptionOrderUniquenessAsync(questionId, dto.Order, null, cancellationToken);
|
||||
|
||||
var option = dto.ToEntity(questionId);
|
||||
var createdOption = await _quizOptionRepository.AddAsync(option, cancellationToken);
|
||||
return createdOption.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing quiz option.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizOptionDto?> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (option == null)
|
||||
return null;
|
||||
|
||||
// Validate that the question exists
|
||||
var question = await _quizQuestionRepository.GetByIdAsync(dto.Id, cancellationToken);
|
||||
if (question == null)
|
||||
throw new ArgumentException("Quiz question does not exist");
|
||||
|
||||
// Validate option order uniqueness within the question
|
||||
await ValidateOptionOrderUniquenessAsync(option.QuizQuestionId, dto.Order, id, cancellationToken);
|
||||
|
||||
option.UpdateFromDto(dto);
|
||||
await _quizOptionRepository.UpdateAsync(option, cancellationToken);
|
||||
return option.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the option order is unique within its question.
|
||||
/// </summary>
|
||||
private async Task ValidateOptionOrderUniquenessAsync(int questionId, int order, int? excludeOptionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await _quizOptionRepository.OrderExistsForQuestionAsync(questionId, order, excludeOptionId, cancellationToken);
|
||||
if (exists)
|
||||
throw new ArgumentException("An option with this order already exists for the specified question");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a quiz option by its ID.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (option == null)
|
||||
return false;
|
||||
|
||||
await _quizOptionRepository.DeleteAsync(option, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits quiz answers and returns the result.
|
||||
/// </summary>
|
||||
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetByLessonAsync(dto.LessonId, cancellationToken);
|
||||
var questionDictionary = questions.ToDictionary(q => q.Id);
|
||||
|
||||
var totalQuestions = questions.Count;
|
||||
var correctAnswers = 0;
|
||||
var questionResults = new List<QuizQuestionResultDto>();
|
||||
|
||||
// Get user's answers
|
||||
var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId);
|
||||
|
||||
foreach (var question in questions)
|
||||
{
|
||||
var isCorrect = false;
|
||||
var userAnswerText = string.Empty;
|
||||
var correctAnswer = question.CorrectAnswer ?? string.Empty;
|
||||
|
||||
if (userAnswers.TryGetValue(question.Id, out var userAnswer))
|
||||
{
|
||||
userAnswerText = userAnswer?.AnswerText ?? string.Empty;
|
||||
|
||||
switch (question.Type)
|
||||
{
|
||||
case QuestionType.MultipleChoice:
|
||||
// Check if the selected option is correct
|
||||
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length == 1)
|
||||
{
|
||||
var selectedOptionId = userAnswer.SelectedOptionIds[0];
|
||||
var isOptionCorrect = await _quizOptionRepository.GetByIdAsync(selectedOptionId, cancellationToken);
|
||||
isCorrect = isOptionCorrect?.IsCorrect == true;
|
||||
}
|
||||
break;
|
||||
|
||||
case QuestionType.MultipleSelect:
|
||||
// Check if all selected options are correct and no incorrect ones are selected
|
||||
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length > 0)
|
||||
{
|
||||
var correctOptions = await _quizOptionRepository.GetCorrectOptionsAsync(question.Id, cancellationToken);
|
||||
var selectedOptions = await _quizOptionRepository.GetByQuestionAsync(question.Id, cancellationToken);
|
||||
|
||||
var correctIds = new HashSet<int>(correctOptions.Select(o => o.Id));
|
||||
var selectedIds = new HashSet<int>(userAnswer.SelectedOptionIds);
|
||||
|
||||
// All selected must be correct, and all correct must be selected
|
||||
isCorrect = selectedIds.SetEquals(correctIds);
|
||||
}
|
||||
break;
|
||||
|
||||
case QuestionType.TrueFalse:
|
||||
// Check if the answer matches
|
||||
if (userAnswer.AnswerText != null)
|
||||
{
|
||||
isCorrect = userAnswer.AnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
break;
|
||||
|
||||
case QuestionType.FillInTheBlank:
|
||||
case QuestionType.ShortAnswer:
|
||||
// Case-insensitive comparison with trimming
|
||||
if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer))
|
||||
{
|
||||
isCorrect = userAnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
break;
|
||||
|
||||
case QuestionType.Matching:
|
||||
// For matching, we would need more complex logic
|
||||
// This is a simplified implementation
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCorrect)
|
||||
correctAnswers++;
|
||||
|
||||
questionResults.Add(new QuizQuestionResultDto(
|
||||
question.Id,
|
||||
question.QuestionText,
|
||||
question.Type,
|
||||
isCorrect,
|
||||
correctAnswer,
|
||||
userAnswerText));
|
||||
}
|
||||
|
||||
var scorePercentage = totalQuestions > 0 ? (double)correctAnswers / totalQuestions * 100 : 0;
|
||||
var passed = scorePercentage >= 80; // Passing score is 80%
|
||||
|
||||
return new QuizResultDto(
|
||||
dto.LessonId,
|
||||
totalQuestions,
|
||||
correctAnswers,
|
||||
scorePercentage,
|
||||
passed,
|
||||
TimeSpan.Zero, // Would be calculated based on actual time taken
|
||||
questionResults);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of quiz questions for a lesson.
|
||||
/// </summary>
|
||||
public virtual async Task<int> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
||||
return questions.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the difficulty distribution for a lesson.
|
||||
/// </summary>
|
||||
public virtual async Task<IDictionary<int, int>> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
||||
var distribution = new Dictionary<int, int>();
|
||||
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
distribution[i] = questions.Count(q => q.Difficulty == i);
|
||||
}
|
||||
|
||||
return distribution;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type distribution for a lesson.
|
||||
/// </summary>
|
||||
public virtual async Task<IDictionary<string, int>> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
||||
var distribution = new Dictionary<string, int>();
|
||||
|
||||
foreach (var type in Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>())
|
||||
{
|
||||
distribution[type.ToString()] = questions.Count(q => q.Type == type);
|
||||
}
|
||||
|
||||
return distribution;
|
||||
}
|
||||
}
|
||||
266
GermanApp/Domain/Entities/QuizQuestion.cs
Normal file
266
GermanApp/Domain/Entities/QuizQuestion.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
namespace GermanApp.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a quiz question for a lesson.
|
||||
/// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.)
|
||||
/// and is associated with a specific lesson.
|
||||
/// </summary>
|
||||
public class QuizQuestion
|
||||
{
|
||||
public int Id { get; private set; }
|
||||
public int LessonId { get; private set; }
|
||||
public string QuestionText { get; private set; } = string.Empty;
|
||||
public QuestionType Type { get; private set; }
|
||||
public string? CorrectAnswer { get; private set; }
|
||||
public int Difficulty { get; private set; } // 1-5 scale
|
||||
public int Order { get; private set; }
|
||||
public bool IsActive { get; private set; } = true;
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public DateTime? UpdatedAt { get; private set; }
|
||||
|
||||
// Navigation property
|
||||
public virtual Lesson? Lesson { get; private set; }
|
||||
public virtual ICollection<QuizOption> Options { get; private set; } = new List<QuizOption>();
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for EF Core deserialization.
|
||||
/// </summary>
|
||||
private QuizQuestion() { }
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a new quiz question.
|
||||
/// </summary>
|
||||
/// <param name="lessonId">ID of the parent lesson</param>
|
||||
/// <param name="questionText">The question text</param>
|
||||
/// <param name="type">The question type</param>
|
||||
/// <param name="correctAnswer">The correct answer</param>
|
||||
/// <param name="difficulty">Difficulty level (1-5)</param>
|
||||
/// <param name="order">Sort order within the lesson</param>
|
||||
public static QuizQuestion Create(
|
||||
int lessonId,
|
||||
string questionText,
|
||||
QuestionType type,
|
||||
string correctAnswer,
|
||||
int difficulty = 3,
|
||||
int order = 1)
|
||||
{
|
||||
if (difficulty < 1 || difficulty > 5)
|
||||
throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(questionText))
|
||||
throw new ArgumentException("Question text cannot be empty", nameof(questionText));
|
||||
|
||||
return new QuizQuestion
|
||||
{
|
||||
LessonId = lessonId,
|
||||
QuestionText = questionText,
|
||||
Type = type,
|
||||
CorrectAnswer = correctAnswer,
|
||||
Difficulty = difficulty,
|
||||
Order = order,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the question text.
|
||||
/// </summary>
|
||||
public void UpdateQuestionText(string newQuestionText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newQuestionText))
|
||||
throw new ArgumentException("Question text cannot be empty", nameof(newQuestionText));
|
||||
|
||||
QuestionText = newQuestionText;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the correct answer.
|
||||
/// </summary>
|
||||
public void UpdateCorrectAnswer(string newCorrectAnswer)
|
||||
{
|
||||
CorrectAnswer = newCorrectAnswer;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the difficulty level.
|
||||
/// </summary>
|
||||
public void UpdateDifficulty(int newDifficulty)
|
||||
{
|
||||
if (newDifficulty < 1 || newDifficulty > 5)
|
||||
throw new ArgumentOutOfRangeException(nameof(newDifficulty), "Difficulty must be between 1 and 5");
|
||||
|
||||
Difficulty = newDifficulty;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the sort order.
|
||||
/// </summary>
|
||||
public void UpdateOrder(int newOrder)
|
||||
{
|
||||
Order = newOrder;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the question type.
|
||||
/// </summary>
|
||||
public void UpdateType(QuestionType newType)
|
||||
{
|
||||
Type = newType;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the associated lesson.
|
||||
/// </summary>
|
||||
public void UpdateLesson(int newLessonId)
|
||||
{
|
||||
LessonId = newLessonId;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the quiz question.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
IsActive = true;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the quiz question.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
IsActive = false;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an option to this question.
|
||||
/// </summary>
|
||||
public void AddOption(QuizOption option)
|
||||
{
|
||||
Options.Add(option);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an option from this question.
|
||||
/// </summary>
|
||||
public void RemoveOption(QuizOption option)
|
||||
{
|
||||
Options.Remove(option);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an option for a multiple-choice quiz question.
|
||||
/// </summary>
|
||||
public class QuizOption
|
||||
{
|
||||
public int Id { get; private set; }
|
||||
public int QuizQuestionId { get; private set; }
|
||||
public string Text { get; private set; } = string.Empty;
|
||||
public bool IsCorrect { get; private set; }
|
||||
public int Order { get; private set; }
|
||||
|
||||
// Navigation property
|
||||
public virtual QuizQuestion? QuizQuestion { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for EF Core deserialization.
|
||||
/// </summary>
|
||||
private QuizOption() { }
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a new quiz option.
|
||||
/// </summary>
|
||||
/// <param name="quizQuestionId">ID of the parent quiz question</param>
|
||||
/// <param name="text">The option text</param>
|
||||
/// <param name="isCorrect">Whether this is the correct answer</param>
|
||||
/// <param name="order">Sort order within the question's options</param>
|
||||
public static QuizOption Create(
|
||||
int quizQuestionId,
|
||||
string text,
|
||||
bool isCorrect = false,
|
||||
int order = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
throw new ArgumentException("Option text cannot be empty", nameof(text));
|
||||
|
||||
return new QuizOption
|
||||
{
|
||||
QuizQuestionId = quizQuestionId,
|
||||
Text = text,
|
||||
IsCorrect = isCorrect,
|
||||
Order = order
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the option text.
|
||||
/// </summary>
|
||||
public void UpdateText(string newText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newText))
|
||||
throw new ArgumentException("Option text cannot be empty", nameof(newText));
|
||||
|
||||
Text = newText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates whether this option is correct.
|
||||
/// </summary>
|
||||
public void UpdateIsCorrect(bool newIsCorrect)
|
||||
{
|
||||
IsCorrect = newIsCorrect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the sort order.
|
||||
/// </summary>
|
||||
public void UpdateOrder(int newOrder)
|
||||
{
|
||||
Order = newOrder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enum representing the types of quiz questions.
|
||||
/// </summary>
|
||||
public enum QuestionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Multiple choice with single correct answer
|
||||
/// </summary>
|
||||
MultipleChoice = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Multiple choice with multiple correct answers
|
||||
/// </summary>
|
||||
MultipleSelect = 1,
|
||||
|
||||
/// <summary>
|
||||
/// True or false question
|
||||
/// </summary>
|
||||
TrueFalse = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Fill-in-the-blank question
|
||||
/// </summary>
|
||||
FillInTheBlank = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Matching question
|
||||
/// </summary>
|
||||
Matching = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Short answer question
|
||||
/// </summary>
|
||||
ShortAnswer = 5
|
||||
}
|
||||
|
|
@ -133,3 +133,75 @@ public interface IUserProgressRepository : IRepository<UserProgress, int>
|
|||
/// </summary>
|
||||
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for QuizQuestion entities.
|
||||
/// </summary>
|
||||
public interface IQuizQuestionRepository : IRepository<QuizQuestion, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets quiz questions by lesson ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all quiz questions ordered by lesson and question order.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active quiz questions by lesson ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a random set of quiz questions for a lesson.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by difficulty level.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by type.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a quiz question with the given order exists in a lesson.
|
||||
/// </summary>
|
||||
Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for QuizOption entities.
|
||||
/// </summary>
|
||||
public interface IQuizOptionRepository : IRepository<QuizOption, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets quiz options by question ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correct option for a question.
|
||||
/// </summary>
|
||||
Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all correct options for a question (for MultipleSelect type).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all options for a question.
|
||||
/// </summary>
|
||||
Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the order exists for a question.
|
||||
/// </summary>
|
||||
Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ 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<QuizQuestion> QuizQuestions { get; set; } = null!;
|
||||
public DbSet<QuizOption> QuizOptions { get; set; } = null!;
|
||||
|
||||
// Note: Value objects are not stored directly as entities.
|
||||
// They are owned by entities and stored as part of the entity's data.
|
||||
|
|
@ -132,6 +134,55 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
|||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure QuizQuestion entity
|
||||
modelBuilder.Entity<QuizQuestion>(builder =>
|
||||
{
|
||||
builder.HasKey(q => q.Id);
|
||||
builder.Property(q => q.LessonId).IsRequired();
|
||||
builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000);
|
||||
builder.Property(q => q.Type).IsRequired();
|
||||
builder.Property(q => q.CorrectAnswer).HasMaxLength(1000);
|
||||
builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3);
|
||||
builder.Property(q => q.Order).IsRequired().HasDefaultValue(1);
|
||||
builder.Property(q => q.IsActive).HasDefaultValue(true);
|
||||
builder.Property(q => q.CreatedAt).IsRequired();
|
||||
builder.Property(q => q.UpdatedAt).IsRequired(false);
|
||||
|
||||
// Foreign key to Lesson
|
||||
builder.HasOne(q => q.Lesson)
|
||||
.WithMany()
|
||||
.HasForeignKey(q => q.LessonId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Unique constraint: one question per lesson per order
|
||||
builder.HasIndex(q => new { q.LessonId, q.Order }).IsUnique();
|
||||
|
||||
// Navigation to options
|
||||
builder.HasMany(q => q.Options)
|
||||
.WithOne(o => o.QuizQuestion)
|
||||
.HasForeignKey(o => o.QuizQuestionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure QuizOption entity
|
||||
modelBuilder.Entity<QuizOption>(builder =>
|
||||
{
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.QuizQuestionId).IsRequired();
|
||||
builder.Property(o => o.Text).IsRequired().HasMaxLength(1000);
|
||||
builder.Property(o => o.IsCorrect).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(o => o.Order).IsRequired().HasDefaultValue(1);
|
||||
|
||||
// Foreign key to QuizQuestion
|
||||
builder.HasOne(o => o.QuizQuestion)
|
||||
.WithMany(q => q.Options)
|
||||
.HasForeignKey(o => o.QuizQuestionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Unique constraint: one option per question per order
|
||||
builder.HasIndex(o => new { o.QuizQuestionId, o.Order }).IsUnique();
|
||||
});
|
||||
|
||||
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
||||
// In a real application, use migrations or a separate seeding mechanism
|
||||
// modelBuilder.Entity<Lesson>().HasData(
|
||||
|
|
|
|||
390
GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs
generated
Normal file
390
GermanApp/Infrastructure/Data/Migrations/20260612050652_AddQuizQuestionTables.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using GermanApp.Infrastructure.Data.DbContext;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GermanApp.Infrastructure.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260612050652_AddQuizQuestionTables")]
|
||||
partial class AddQuizQuestionTables
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("LevelId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LevelId", "Order")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Lessons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Order")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Levels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsCorrect")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<int>("QuizQuestionId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("QuizQuestionId", "Order")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("QuizOptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrectAnswer")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Difficulty")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(3);
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("LessonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("QuestionText")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LessonId", "Order")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("QuizQuestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CurrentLevel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasDefaultValue("A1");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("Streak")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("TotalPoints")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTime>("LastAttemptDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LessonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("QuizScore")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LessonId");
|
||||
|
||||
b.HasIndex("UserId", "LessonId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
||||
.WithMany()
|
||||
.HasForeignKey("LevelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Level");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
||||
.WithMany("Options")
|
||||
.HasForeignKey("QuizQuestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QuizQuestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||
.WithMany()
|
||||
.HasForeignKey("LessonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Lesson");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||
.WithMany()
|
||||
.HasForeignKey("LessonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Lesson");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||
{
|
||||
b.Navigation("Options");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GermanApp.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuizQuestionTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QuizQuestions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
LessonId = table.Column<int>(type: "integer", nullable: false),
|
||||
QuestionText = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
|
||||
Type = table.Column<int>(type: "integer", nullable: false),
|
||||
CorrectAnswer = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
Difficulty = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
|
||||
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QuizQuestions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_QuizQuestions_Lessons_LessonId",
|
||||
column: x => x.LessonId,
|
||||
principalTable: "Lessons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QuizOptions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
QuizQuestionId = table.Column<int>(type: "integer", nullable: false),
|
||||
Text = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
IsCorrect = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QuizOptions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_QuizOptions_QuizQuestions_QuizQuestionId",
|
||||
column: x => x.QuizQuestionId,
|
||||
principalTable: "QuizQuestions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QuizOptions_QuizQuestionId_Order",
|
||||
table: "QuizOptions",
|
||||
columns: new[] { "QuizQuestionId", "Order" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QuizQuestions_LessonId_Order",
|
||||
table: "QuizQuestions",
|
||||
columns: new[] { "LessonId", "Order" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "QuizOptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "QuizQuestions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,6 +102,92 @@ namespace GermanApp.Migrations
|
|||
b.ToTable("Levels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsCorrect")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<int>("QuizQuestionId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("QuizQuestionId", "Order")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("QuizOptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrectAnswer")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Difficulty")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(3);
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("LessonId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("QuestionText")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LessonId", "Order")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("QuizQuestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -241,6 +327,28 @@ namespace GermanApp.Migrations
|
|||
b.Navigation("Level");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
||||
.WithMany("Options")
|
||||
.HasForeignKey("QuizQuestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QuizQuestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||
.WithMany()
|
||||
.HasForeignKey("LessonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Lesson");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||
|
|
@ -268,6 +376,11 @@ namespace GermanApp.Migrations
|
|||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||
{
|
||||
b.Navigation("Options");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
using GermanApp.Infrastructure.Data.DbContext;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core implementation of IQuizOptionRepository.
|
||||
/// This is part of the Infrastructure layer.
|
||||
/// </summary>
|
||||
public class QuizOptionRepository : IQuizOptionRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public QuizOptionRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<QuizOption?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizOptions
|
||||
.Include(o => o.QuizQuestion)
|
||||
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizOption>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizOptions
|
||||
.Include(o => o.QuizQuestion)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<QuizOption> AddAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.QuizOptions.AddAsync(entity, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.QuizOptions.Update(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.QuizOptions.Remove(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizOptions
|
||||
.AnyAsync(o => o.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizOptions
|
||||
.Include(o => o.QuizQuestion)
|
||||
.Where(o => o.QuizQuestionId == questionId)
|
||||
.OrderBy(o => o.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizOptions
|
||||
.Include(o => o.QuizQuestion)
|
||||
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
|
||||
.OrderBy(o => o.Order)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizOptions
|
||||
.Include(o => o.QuizQuestion)
|
||||
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
|
||||
.OrderBy(o => o.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _context.QuizOptions
|
||||
.Where(o => o.QuizQuestionId == questionId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
_context.QuizOptions.RemoveRange(options);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.QuizOptions
|
||||
.Where(o => o.QuizQuestionId == questionId && o.Order == order);
|
||||
|
||||
if (excludeId.HasValue)
|
||||
{
|
||||
query = query.Where(o => o.Id != excludeId.Value);
|
||||
}
|
||||
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
using GermanApp.Infrastructure.Data.DbContext;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core implementation of IQuizQuestionRepository.
|
||||
/// This is part of the Infrastructure layer.
|
||||
/// </summary>
|
||||
public class QuizQuestionRepository : IQuizQuestionRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public QuizQuestionRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<QuizQuestion?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<QuizQuestion> AddAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.QuizQuestions.AddAsync(entity, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.QuizQuestions.Update(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.QuizQuestions.Remove(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.AnyAsync(q => q.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||
.Where(q => q.LessonId == lessonId)
|
||||
.OrderBy(q => q.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||
.OrderBy(q => q.Lesson.Order)
|
||||
.ThenBy(q => q.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||
.Where(q => q.LessonId == lessonId && q.IsActive)
|
||||
.OrderBy(q => q.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
||||
.Where(q => q.LessonId == lessonId && q.IsActive)
|
||||
.OrderBy(q => Guid.NewGuid()) // Random ordering
|
||||
.Take(count)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options)
|
||||
.Where(q => q.Difficulty == difficulty)
|
||||
.OrderBy(q => q.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.QuizQuestions
|
||||
.Include(q => q.Lesson)
|
||||
.Include(q => q.Options)
|
||||
.Where(q => q.Type == type)
|
||||
.OrderBy(q => q.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.QuizQuestions
|
||||
.Where(q => q.LessonId == lessonId && q.Order == order);
|
||||
|
||||
if (excludeId.HasValue)
|
||||
{
|
||||
query = query.Where(q => q.Id != excludeId.Value);
|
||||
}
|
||||
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,47 @@ public static class SeedDataExtension
|
|||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons.");
|
||||
// Seed quiz questions for the lessons
|
||||
dbContext.QuizQuestions.AddRange(new[]
|
||||
{
|
||||
// Questions for Greetings lesson (Lesson 1)
|
||||
QuizQuestion.Create(1, "What is the German word for 'Hello'?", QuestionType.FillInTheBlank, "Hallo", 1, 1),
|
||||
QuizQuestion.Create(1, "How do you say 'Good morning' in German?", QuestionType.FillInTheBlank, "Guten Morgen", 2, 2),
|
||||
QuizQuestion.Create(1, "What is the German word for 'Goodbye'?", QuestionType.MultipleChoice, "Auf Wiedersehen", 1, 3),
|
||||
// Questions for Numbers lesson (Lesson 2)
|
||||
QuizQuestion.Create(2, "What is '1' in German?", QuestionType.FillInTheBlank, "eins", 1, 1),
|
||||
QuizQuestion.Create(2, "What is '10' in German?", QuestionType.FillInTheBlank, "zehn", 2, 2),
|
||||
QuizQuestion.Create(2, "Which number is 'zwanzig'?", QuestionType.MultipleChoice, "20", 2, 3),
|
||||
// Questions for Grammar Basics lesson (Lesson 3)
|
||||
QuizQuestion.Create(3, "Is 'Der Mann' masculine?", QuestionType.TrueFalse, "True", 3, 1),
|
||||
QuizQuestion.Create(3, "What is the article for 'Frau'?", QuestionType.MultipleChoice, "die", 2, 2)
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Get the quiz question IDs after save
|
||||
var quizQuestions = await dbContext.QuizQuestions.OrderBy(q => q.Id).ToListAsync();
|
||||
|
||||
// Add options for multiple choice questions
|
||||
// greetingsQ3 is ID 3 (3rd question created)
|
||||
dbContext.QuizOptions.AddRange(new[]
|
||||
{
|
||||
QuizOption.Create(quizQuestions[2].Id, "Hallo", false, 1),
|
||||
QuizOption.Create(quizQuestions[2].Id, "Danke", false, 2),
|
||||
QuizOption.Create(quizQuestions[2].Id, "Auf Wiedersehen", true, 3),
|
||||
QuizOption.Create(quizQuestions[2].Id, "Bitte", false, 4),
|
||||
// Numbers Q3 is ID 6
|
||||
QuizOption.Create(quizQuestions[5].Id, "10", false, 1),
|
||||
QuizOption.Create(quizQuestions[5].Id, "20", true, 2),
|
||||
QuizOption.Create(quizQuestions[5].Id, "30", false, 3),
|
||||
QuizOption.Create(quizQuestions[5].Id, "100", false, 4),
|
||||
// Grammar Q2 is ID 8
|
||||
QuizOption.Create(quizQuestions[7].Id, "der", false, 1),
|
||||
QuizOption.Create(quizQuestions[7].Id, "die", true, 2),
|
||||
QuizOption.Create(quizQuestions[7].Id, "das", false, 3)
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Database seeded with levels, admin user, test user, sample lessons, and quiz questions.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
287
GermanApp/Presentation/Controllers/QuizQuestionsController.cs
Normal file
287
GermanApp/Presentation/Controllers/QuizQuestionsController.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Application.Services;
|
||||
using GermanApp.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace GermanApp.Presentation.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API controller for managing quiz questions.
|
||||
/// This is part of the Presentation layer.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class QuizQuestionsController : ControllerBase
|
||||
{
|
||||
private readonly QuizQuestionService _quizQuestionService;
|
||||
|
||||
public QuizQuestionsController(QuizQuestionService quizQuestionService)
|
||||
{
|
||||
_quizQuestionService = quizQuestionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all quiz questions.
|
||||
/// </summary>
|
||||
/// <returns>List of all quiz questions</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
||||
return Ok(questions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active quiz questions for a specific lesson.
|
||||
/// </summary>
|
||||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>List of quiz questions for the specified lesson</returns>
|
||||
[HttpGet("by-lesson/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
||||
return Ok(questions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific quiz question by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The quiz question ID</param>
|
||||
/// <returns>The quiz question with the specified ID</returns>
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
||||
if (question == null)
|
||||
return NotFound();
|
||||
return Ok(question);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a random set of quiz questions for a lesson.
|
||||
/// </summary>
|
||||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <param name="count">Number of questions to return</param>
|
||||
/// <returns>List of random quiz questions for the lesson</returns>
|
||||
[HttpGet("random/{lessonId}/{count}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
||||
return Ok(questions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by difficulty level.
|
||||
/// </summary>
|
||||
/// <param name="difficulty">The difficulty level (1-5)</param>
|
||||
/// <returns>List of quiz questions with the specified difficulty</returns>
|
||||
[HttpGet("by-difficulty/{difficulty}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
||||
return Ok(questions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz questions by type.
|
||||
/// </summary>
|
||||
/// <param name="type">The question type</param>
|
||||
/// <returns>List of quiz questions with the specified type</returns>
|
||||
[HttpGet("by-type/{type}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
||||
return Ok(questions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new quiz question.
|
||||
/// </summary>
|
||||
/// <param name="dto">The quiz question data</param>
|
||||
/// <returns>The created quiz question</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionService.CreateQuizQuestionAsync(dto, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetQuizQuestionByIdAsync), new { id = question.Id }, question);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing quiz question.
|
||||
/// </summary>
|
||||
/// <param name="id">The quiz question ID</param>
|
||||
/// <param name="dto">The updated quiz question data</param>
|
||||
/// <returns>The updated quiz question</returns>
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await _quizQuestionService.UpdateQuizQuestionAsync(id, dto, cancellationToken);
|
||||
if (question == null)
|
||||
return NotFound();
|
||||
return Ok(question);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a quiz question by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The quiz question ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _quizQuestionService.DeleteQuizQuestionAsync(id, cancellationToken);
|
||||
if (!result)
|
||||
return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets quiz options for a specific question.
|
||||
/// </summary>
|
||||
/// <param name="questionId">The question ID</param>
|
||||
/// <returns>List of quiz options for the question</returns>
|
||||
[HttpGet("options/{questionId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
||||
return Ok(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correct option for a question.
|
||||
/// </summary>
|
||||
/// <param name="questionId">The question ID</param>
|
||||
/// <returns>The correct option for the question</returns>
|
||||
[HttpGet("correct-option/{questionId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
||||
if (option == null)
|
||||
return NotFound();
|
||||
return Ok(option);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all correct options for a question (for MultipleSelect type).
|
||||
/// </summary>
|
||||
/// <param name="questionId">The question ID</param>
|
||||
/// <returns>List of correct options for the question</returns>
|
||||
[HttpGet("correct-options/{questionId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
||||
return Ok(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new quiz option for a question.
|
||||
/// </summary>
|
||||
/// <param name="questionId">The question ID</param>
|
||||
/// <param name="dto">The quiz option data</param>
|
||||
/// <returns>The created quiz option</returns>
|
||||
[HttpPost("options/{questionId}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizQuestionService.CreateQuizOptionAsync(questionId, dto, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetQuizOptionsByQuestionAsync), new { questionId }, option);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing quiz option.
|
||||
/// </summary>
|
||||
/// <param name="id">The quiz option ID</param>
|
||||
/// <param name="dto">The updated quiz option data</param>
|
||||
/// <returns>The updated quiz option</returns>
|
||||
[HttpPut("options/{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var option = await _quizQuestionService.UpdateQuizOptionAsync(id, dto, cancellationToken);
|
||||
if (option == null)
|
||||
return NotFound();
|
||||
return Ok(option);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a quiz option by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The quiz option ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("options/{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _quizQuestionService.DeleteQuizOptionAsync(id, cancellationToken);
|
||||
if (!result)
|
||||
return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits quiz answers and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID</param>
|
||||
/// <param name="dto">The quiz answers submission</param>
|
||||
/// <returns>The quiz result</returns>
|
||||
[HttpPost("submit/{userId}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _quizQuestionService.SubmitQuizAnswersAsync(userId, dto, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of quiz questions for a lesson.
|
||||
/// </summary>
|
||||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The count of quiz questions for the lesson</returns>
|
||||
[HttpGet("count/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
||||
return Ok(new { lessonId, count });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the difficulty distribution for a lesson.
|
||||
/// </summary>
|
||||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The difficulty distribution for the lesson</returns>
|
||||
[HttpGet("difficulty-distribution/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
||||
return Ok(distribution);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type distribution for a lesson.
|
||||
/// </summary>
|
||||
/// <param name="lessonId">The lesson ID</param>
|
||||
/// <returns>The type distribution for the lesson</returns>
|
||||
[HttpGet("type-distribution/{lessonId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
||||
return Ok(distribution);
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +134,8 @@ try
|
|||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
|
||||
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
|
||||
|
||||
// ============================================
|
||||
// INFRASTRUCTURE LAYER - AI Services
|
||||
|
|
@ -169,6 +171,7 @@ try
|
|||
builder.Services.AddScoped<LevelService>();
|
||||
builder.Services.AddScoped<LessonService>();
|
||||
builder.Services.AddScoped<ProgressService>();
|
||||
builder.Services.AddScoped<QuizQuestionService>();
|
||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -204,10 +207,20 @@ try
|
|||
app.MapLessonsEndpoints();
|
||||
|
||||
// Seed database with initial data
|
||||
if (app.Environment.IsDevelopment())
|
||||
// Run migrations and seed in Development, or just migrations in Production/Docker
|
||||
// In Docker, we can control seeding via environment variable
|
||||
var shouldSeed = app.Environment.IsDevelopment() ||
|
||||
(app.Configuration.GetValue<bool>("SeedDatabase"));
|
||||
|
||||
if (shouldSeed)
|
||||
{
|
||||
await app.SeedDatabaseAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// In Production/Docker, ensure migrations are applied with retry logic
|
||||
await ApplyMigrationsWithRetry(app, maxRetries: 10, delaySeconds: 5);
|
||||
}
|
||||
|
||||
// Keep original WeatherForecast endpoint for reference
|
||||
app.MapGet("/weatherforecast", () =>
|
||||
|
|
@ -230,6 +243,36 @@ try
|
|||
.WithName("GetWeatherForecast");
|
||||
|
||||
app.Run();
|
||||
|
||||
// Helper method to apply migrations with retry logic for Docker
|
||||
static async Task ApplyMigrationsWithRetry(WebApplication app, int maxRetries, int delaySeconds)
|
||||
{
|
||||
int retryCount = 0;
|
||||
while (retryCount < maxRetries)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
dbContext.Database.Migrate();
|
||||
Log.Information("Database migrations applied in Production environment");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
retryCount++;
|
||||
Log.Warning(ex, "Failed to apply database migrations (attempt {Attempt}/{MaxAttempts}). Retrying in {DelaySeconds} seconds...",
|
||||
retryCount, maxRetries, delaySeconds);
|
||||
if (retryCount >= maxRetries)
|
||||
{
|
||||
Log.Fatal(ex, "Failed to apply database migrations after {MaxAttempts} attempts", maxRetries);
|
||||
throw;
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
321
Tests/Unit/Application/Services/QuizQuestionServiceTests.cs
Normal file
321
Tests/Unit/Application/Services/QuizQuestionServiceTests.cs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Application.Services;
|
||||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
|
||||
namespace GermanApp.Tests.Unit.Application.Services;
|
||||
|
||||
[TestClass]
|
||||
public class QuizQuestionServiceTests
|
||||
{
|
||||
private Mock<IQuizQuestionRepository> _mockQuizQuestionRepo;
|
||||
private Mock<IQuizOptionRepository> _mockQuizOptionRepo;
|
||||
private Mock<ILessonRepository> _mockLessonRepo;
|
||||
private QuizQuestionService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_mockQuizQuestionRepo = new Mock<IQuizQuestionRepository>();
|
||||
_mockQuizOptionRepo = new Mock<IQuizOptionRepository>();
|
||||
_mockLessonRepo = new Mock<ILessonRepository>();
|
||||
_service = new QuizQuestionService(
|
||||
_mockQuizQuestionRepo.Object,
|
||||
_mockQuizOptionRepo.Object,
|
||||
_mockLessonRepo.Object);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetAllQuizQuestionsAsync_ReturnsMappedDtoList()
|
||||
{
|
||||
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
||||
var questions = new List<QuizQuestion>
|
||||
{
|
||||
QuizQuestion.Create(1, "Question 1", QuestionType.MultipleChoice, "Answer 1", 3, 1)
|
||||
};
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetAllOrderedAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(questions);
|
||||
|
||||
var result = await _service.GetAllQuizQuestionsAsync();
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("Question 1", result[0].QuestionText);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetQuizQuestionByIdAsync_WithExistingId_ReturnsQuestion()
|
||||
{
|
||||
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Test Answer", 3, 1);
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(question);
|
||||
|
||||
var result = await _service.GetQuizQuestionByIdAsync(1);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Test Question", result.QuestionText);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetQuizQuestionByIdAsync_WithNonExistingId_ReturnsNull()
|
||||
{
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((QuizQuestion?)null);
|
||||
|
||||
var result = await _service.GetQuizQuestionByIdAsync(999);
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetQuizQuestionsByLessonAsync_ReturnsFilteredQuestions()
|
||||
{
|
||||
var questions = new List<QuizQuestion>
|
||||
{
|
||||
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1)
|
||||
};
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(questions);
|
||||
|
||||
var result = await _service.GetQuizQuestionsByLessonAsync(1);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("Q1", result[0].QuestionText);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetActiveQuizQuestionsByLessonAsync_ReturnsOnlyActive()
|
||||
{
|
||||
var activeQuestion = QuizQuestion.Create(1, "Active", QuestionType.MultipleChoice, "A1", 3, 1);
|
||||
var inactiveQuestion = QuizQuestion.Create(1, "Inactive", QuestionType.MultipleChoice, "A2", 3, 2);
|
||||
inactiveQuestion.Deactivate();
|
||||
|
||||
var questions = new List<QuizQuestion> { activeQuestion, inactiveQuestion };
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<QuizQuestion> { activeQuestion });
|
||||
|
||||
var result = await _service.GetActiveQuizQuestionsByLessonAsync(1);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("Active", result[0].QuestionText);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CreateQuizQuestionAsync_WithValidDtoAndValidLesson_CreatesQuestion()
|
||||
{
|
||||
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
||||
var options = new List<CreateQuizOptionDto>
|
||||
{
|
||||
new CreateQuizOptionDto("Option 1", true, 1),
|
||||
new CreateQuizOptionDto("Option 2", false, 2)
|
||||
};
|
||||
var dto = new CreateQuizQuestionDto(
|
||||
1, "New Question", QuestionType.MultipleChoice, "Answer 1", 3, 1, options);
|
||||
|
||||
var createdQuestion = QuizQuestion.Create(1, "New Question", QuestionType.MultipleChoice, "Answer 1", 3, 1);
|
||||
|
||||
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lesson);
|
||||
_mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
_mockQuizQuestionRepo.Setup(r => r.AddAsync(It.IsAny<QuizQuestion>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(createdQuestion);
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(createdQuestion.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(createdQuestion);
|
||||
|
||||
var result = await _service.CreateQuizQuestionAsync(dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("New Question", result.QuestionText);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UpdateQuizQuestionAsync_WithExistingQuestion_UpdatesQuestion()
|
||||
{
|
||||
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
||||
var existing = QuizQuestion.Create(1, "Old Question", QuestionType.MultipleChoice, "Old Answer", 3, 1);
|
||||
var options = new List<UpdateQuizOptionDto>
|
||||
{
|
||||
new UpdateQuizOptionDto(1, "Updated Option", true, 1)
|
||||
};
|
||||
var dto = new UpdateQuizQuestionDto(1, "Updated Question", QuestionType.TrueFalse, "New Answer", 4, 1, true, options);
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing);
|
||||
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lesson);
|
||||
_mockQuizQuestionRepo.Setup(r => r.OrderExistsInLessonAsync(1, 1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
_mockQuizQuestionRepo.Setup(r => r.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing);
|
||||
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.UpdateQuizQuestionAsync(1, dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Updated Question", result.QuestionText);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DeleteQuizQuestionAsync_WithExistingQuestion_ReturnsTrue()
|
||||
{
|
||||
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(question);
|
||||
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_mockQuizQuestionRepo.Setup(r => r.DeleteAsync(question, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.DeleteQuizQuestionAsync(1);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetQuizOptionsByQuestionAsync_ReturnsOptions()
|
||||
{
|
||||
var options = new List<QuizOption>
|
||||
{
|
||||
QuizOption.Create(1, "Option 1", true, 1),
|
||||
QuizOption.Create(1, "Option 2", false, 2)
|
||||
};
|
||||
|
||||
_mockQuizOptionRepo.Setup(r => r.GetByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(options);
|
||||
|
||||
var result = await _service.GetQuizOptionsByQuestionAsync(1);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetCorrectOptionAsync_ReturnsCorrectOption()
|
||||
{
|
||||
var option = QuizOption.Create(1, "Correct Option", true, 1);
|
||||
|
||||
_mockQuizOptionRepo.Setup(r => r.GetCorrectOptionAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(option);
|
||||
|
||||
var result = await _service.GetCorrectOptionAsync(1);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.IsCorrect);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CreateQuizOptionAsync_WithValidData_CreatesOption()
|
||||
{
|
||||
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
|
||||
var dto = new CreateQuizOptionDto("New Option", true, 1);
|
||||
var createdOption = QuizOption.Create(1, "New Option", true, 1);
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(question);
|
||||
_mockQuizOptionRepo.Setup(r => r.OrderExistsForQuestionAsync(1, 1, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
_mockQuizOptionRepo.Setup(r => r.AddAsync(It.IsAny<QuizOption>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(createdOption);
|
||||
|
||||
var result = await _service.CreateQuizOptionAsync(1, dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("New Option", result.Text);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SubmitQuizAnswersAsync_WithCorrectAnswers_ReturnsPassedResult()
|
||||
{
|
||||
var question = QuizQuestion.Create(1, "What is 2+2?", QuestionType.FillInTheBlank, "4", 3, 1);
|
||||
var questions = new List<QuizQuestion> { question };
|
||||
var answers = new List<QuizAnswerDto>
|
||||
{
|
||||
new QuizAnswerDto(1, "4", null)
|
||||
};
|
||||
var dto = new SubmitQuizAnswersDto(1, answers);
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(questions);
|
||||
|
||||
var result = await _service.SubmitQuizAnswersAsync(1, dto);
|
||||
|
||||
Assert.AreEqual(1, result.TotalQuestions);
|
||||
Assert.AreEqual(1, result.CorrectAnswers);
|
||||
Assert.AreEqual(100, result.ScorePercentage);
|
||||
Assert.IsTrue(result.Passed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SubmitQuizAnswersAsync_WithIncorrectAnswers_ReturnsFailedResult()
|
||||
{
|
||||
var question = QuizQuestion.Create(1, "What is 2+2?", QuestionType.FillInTheBlank, "4", 3, 1);
|
||||
var questions = new List<QuizQuestion> { question };
|
||||
var answers = new List<QuizAnswerDto>
|
||||
{
|
||||
new QuizAnswerDto(1, "5", null) // Wrong answer
|
||||
};
|
||||
var dto = new SubmitQuizAnswersDto(1, answers);
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(questions);
|
||||
|
||||
var result = await _service.SubmitQuizAnswersAsync(1, dto);
|
||||
|
||||
Assert.AreEqual(1, result.TotalQuestions);
|
||||
Assert.AreEqual(0, result.CorrectAnswers);
|
||||
Assert.AreEqual(0, result.ScorePercentage);
|
||||
Assert.IsFalse(result.Passed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetQuizQuestionCountForLessonAsync_ReturnsCount()
|
||||
{
|
||||
var questions = new List<QuizQuestion>
|
||||
{
|
||||
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1),
|
||||
QuizQuestion.Create(1, "Q2", QuestionType.MultipleChoice, "A2", 3, 2)
|
||||
};
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(questions);
|
||||
|
||||
var result = await _service.GetQuizQuestionCountForLessonAsync(1);
|
||||
|
||||
Assert.AreEqual(2, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetDifficultyDistributionAsync_ReturnsDistribution()
|
||||
{
|
||||
var questions = new List<QuizQuestion>
|
||||
{
|
||||
QuizQuestion.Create(1, "Easy", QuestionType.MultipleChoice, "A1", 1, 1),
|
||||
QuizQuestion.Create(1, "Medium", QuestionType.MultipleChoice, "A2", 3, 2),
|
||||
QuizQuestion.Create(1, "Hard", QuestionType.MultipleChoice, "A3", 5, 3)
|
||||
};
|
||||
|
||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(questions);
|
||||
|
||||
var result = await _service.GetDifficultyDistributionAsync(1);
|
||||
|
||||
Assert.AreEqual(1, result[1]); // Easy
|
||||
Assert.AreEqual(0, result[2]); // No questions with difficulty 2
|
||||
Assert.AreEqual(1, result[3]); // Medium
|
||||
Assert.AreEqual(0, result[4]); // No questions with difficulty 4
|
||||
Assert.AreEqual(1, result[5]); // Hard
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ services:
|
|||
ASPNETCORE_ENVIRONMENT: Development
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
ConnectionStrings__DefaultConnection: Host=db;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres
|
||||
SeedDatabase: "true"
|
||||
# JWT Configuration (from Woodpecker secrets or environment)
|
||||
Jwt__Key: ${JWT_KEY:-your-super-secret-key-at-least-32-characters-long}
|
||||
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue