Compare commits
10 commits
a492cdd790
...
c65214737e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65214737e | ||
|
|
b94e5fd4a8 | ||
|
|
0ff6218ff1 | ||
|
|
a9e9a6e457 | ||
|
|
be8c58671d | ||
|
|
8e05c2b6e2 | ||
|
|
ddc7908151 | ||
|
|
6ac2210e50 | ||
|
|
ba81359afa | ||
|
|
81350042a0 |
26 changed files with 2384 additions and 96 deletions
|
|
@ -10,8 +10,11 @@ public record LessonDto(
|
|||
int Id,
|
||||
string Title,
|
||||
string Description,
|
||||
int Level,
|
||||
string LevelDescription,
|
||||
int LevelId,
|
||||
string LevelName,
|
||||
string LevelCode,
|
||||
int Order,
|
||||
string Topic,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
|
||||
|
|
@ -21,7 +24,9 @@ public record LessonDto(
|
|||
public record CreateLessonDto(
|
||||
string Title,
|
||||
string Description,
|
||||
int Level);
|
||||
int LevelId,
|
||||
int Order,
|
||||
string Topic);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for updating an existing Lesson.
|
||||
|
|
@ -29,7 +34,9 @@ public record CreateLessonDto(
|
|||
public record UpdateLessonDto(
|
||||
string Title,
|
||||
string Description,
|
||||
int Level);
|
||||
int LevelId,
|
||||
int Order,
|
||||
string Topic);
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping between Lesson entity and DTOs.
|
||||
|
|
@ -40,21 +47,23 @@ public static class LessonDtoExtensions
|
|||
lesson.Id,
|
||||
lesson.Title,
|
||||
lesson.Description,
|
||||
lesson.Level,
|
||||
GetLevelDescription(lesson.Level),
|
||||
lesson.LevelId,
|
||||
lesson.Level?.Name ?? "Unknown",
|
||||
lesson.Level?.Code ?? "??",
|
||||
lesson.Order,
|
||||
lesson.Topic,
|
||||
lesson.CreatedAt,
|
||||
lesson.UpdatedAt);
|
||||
|
||||
private static string GetLevelDescription(int level) => level switch
|
||||
{
|
||||
1 => "A1 (Beginner)",
|
||||
2 => "A2 (Elementary)",
|
||||
3 => "B1 (Intermediate)",
|
||||
4 => "B2 (Upper Intermediate)",
|
||||
5 => "C1 (Advanced)",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
public static Lesson ToEntity(this CreateLessonDto dto) =>
|
||||
Lesson.Create(dto.Title, dto.Description, dto.Level);
|
||||
Lesson.Create(dto.LevelId, dto.Title, dto.Order, dto.Topic, dto.Description);
|
||||
|
||||
public static void UpdateFromDto(this Lesson lesson, UpdateLessonDto dto)
|
||||
{
|
||||
lesson.UpdateTitle(dto.Title);
|
||||
lesson.UpdateDescription(dto.Description);
|
||||
lesson.UpdateLevel(dto.LevelId);
|
||||
lesson.UpdateOrder(dto.Order);
|
||||
lesson.UpdateTopic(dto.Topic);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
GermanApp/Application/DTOs/LevelDto.cs
Normal file
51
GermanApp/Application/DTOs/LevelDto.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using GermanApp.Domain.Entities;
|
||||
|
||||
namespace GermanApp.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for Level - used for API responses.
|
||||
/// This is a read-only representation of a Level entity.
|
||||
/// </summary>
|
||||
public record LevelDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string Code,
|
||||
int Order);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for creating a new Level.
|
||||
/// </summary>
|
||||
public record CreateLevelDto(
|
||||
string Name,
|
||||
string Code,
|
||||
int Order);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for updating an existing Level.
|
||||
/// </summary>
|
||||
public record UpdateLevelDto(
|
||||
string Name,
|
||||
string Code,
|
||||
int Order);
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping between Level entity and DTOs.
|
||||
/// </summary>
|
||||
public static class LevelDtoExtensions
|
||||
{
|
||||
public static LevelDto ToDto(this Level level) => new(
|
||||
level.Id,
|
||||
level.Name,
|
||||
level.Code,
|
||||
level.Order);
|
||||
|
||||
public static Level ToEntity(this CreateLevelDto dto) =>
|
||||
Level.Create(dto.Name, dto.Code, dto.Order);
|
||||
|
||||
public static void UpdateFromDto(this Level level, UpdateLevelDto dto)
|
||||
{
|
||||
level.UpdateName(dto.Name);
|
||||
level.UpdateCode(dto.Code);
|
||||
level.UpdateOrder(dto.Order);
|
||||
}
|
||||
}
|
||||
80
GermanApp/Application/DTOs/UserProgressDto.cs
Normal file
80
GermanApp/Application/DTOs/UserProgressDto.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using GermanApp.Domain.Entities;
|
||||
|
||||
namespace GermanApp.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for UserProgress - used for API responses.
|
||||
/// This is a read-only representation of a UserProgress entity.
|
||||
/// </summary>
|
||||
public record UserProgressDto(
|
||||
int Id,
|
||||
int UserId,
|
||||
int LessonId,
|
||||
bool IsCompleted,
|
||||
int QuizScore,
|
||||
DateTime LastAttemptDate,
|
||||
string? LessonTitle = null,
|
||||
string? LevelName = null,
|
||||
string? LevelCode = null);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for creating/updating user progress.
|
||||
/// </summary>
|
||||
public record UpdateUserProgressDto(
|
||||
int LessonId,
|
||||
bool IsCompleted,
|
||||
int QuizScore);
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object for level completion summary.
|
||||
/// </summary>
|
||||
public record LevelCompletionDto(
|
||||
int LevelId,
|
||||
string LevelName,
|
||||
string LevelCode,
|
||||
int TotalLessons,
|
||||
int CompletedLessons,
|
||||
double CompletionPercentage);
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping between UserProgress entity and DTOs.
|
||||
/// </summary>
|
||||
public static class UserProgressDtoExtensions
|
||||
{
|
||||
public static UserProgressDto ToDto(this UserProgress userProgress) => new(
|
||||
userProgress.Id,
|
||||
userProgress.UserId,
|
||||
userProgress.LessonId,
|
||||
userProgress.IsCompleted,
|
||||
userProgress.QuizScore,
|
||||
userProgress.LastAttemptDate,
|
||||
userProgress.Lesson?.Title,
|
||||
userProgress.Lesson?.Level?.Name,
|
||||
userProgress.Lesson?.Level?.Code);
|
||||
|
||||
public static UserProgress ToEntity(this UpdateUserProgressDto dto, int userId)
|
||||
{
|
||||
var progress = UserProgress.Create(userId, dto.LessonId);
|
||||
if (dto.IsCompleted)
|
||||
{
|
||||
progress.MarkAsCompleted(dto.QuizScore);
|
||||
}
|
||||
else
|
||||
{
|
||||
progress.UpdateQuizScore(dto.QuizScore);
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
public static void UpdateFromDto(this UserProgress userProgress, UpdateUserProgressDto dto)
|
||||
{
|
||||
if (dto.IsCompleted)
|
||||
{
|
||||
userProgress.MarkAsCompleted(dto.QuizScore);
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.UpdateQuizScore(dto.QuizScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
159
GermanApp/Application/Services/LessonService.cs
Normal file
159
GermanApp/Application/Services/LessonService.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
|
||||
namespace GermanApp.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application service for managing lessons.
|
||||
/// This is part of the Application layer.
|
||||
/// </summary>
|
||||
public class LessonService
|
||||
{
|
||||
private readonly ILessonRepository _lessonRepository;
|
||||
private readonly ILevelRepository _levelRepository;
|
||||
|
||||
public LessonService(ILessonRepository lessonRepository, ILevelRepository levelRepository)
|
||||
{
|
||||
_lessonRepository = lessonRepository;
|
||||
_levelRepository = levelRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all lessons.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonRepository.GetAllAsync(cancellationToken);
|
||||
return lessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all lessons ordered by level and lesson order.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonRepository.GetAllOrderedAsync(cancellationToken);
|
||||
return lessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a lesson by its ID.
|
||||
/// </summary>
|
||||
public async Task<LessonDto?> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
||||
return lesson?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lessons by level ID.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
||||
return lessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level1Lessons = await _lessonRepository.GetByLevelAsync(1, cancellationToken);
|
||||
var level2Lessons = await _lessonRepository.GetByLevelAsync(2, cancellationToken);
|
||||
var allLessons = level1Lessons.Concat(level2Lessons)
|
||||
.OrderBy(l => l.LevelId)
|
||||
.ThenBy(l => l.Order)
|
||||
.ToList();
|
||||
return allLessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level4Lessons = await _lessonRepository.GetByLevelAsync(4, cancellationToken);
|
||||
var level5Lessons = await _lessonRepository.GetByLevelAsync(5, cancellationToken);
|
||||
var allLessons = level4Lessons.Concat(level5Lessons)
|
||||
.OrderBy(l => l.LevelId)
|
||||
.ThenBy(l => l.Order)
|
||||
.ToList();
|
||||
return allLessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new lesson.
|
||||
/// </summary>
|
||||
public async Task<LessonDto> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate that the level exists
|
||||
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
|
||||
if (level == null)
|
||||
throw new ArgumentException("Level does not exist");
|
||||
|
||||
var lesson = dto.ToEntity();
|
||||
var createdLesson = await _lessonRepository.AddAsync(lesson, cancellationToken);
|
||||
return createdLesson.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing lesson.
|
||||
/// </summary>
|
||||
public async Task<LessonDto?> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (lesson == null)
|
||||
return null;
|
||||
|
||||
// Validate that the level exists
|
||||
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
|
||||
if (level == null)
|
||||
throw new ArgumentException("Level does not exist");
|
||||
|
||||
lesson.UpdateFromDto(dto);
|
||||
await _lessonRepository.UpdateAsync(lesson, cancellationToken);
|
||||
return lesson.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a lesson by its ID.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (lesson == null)
|
||||
return false;
|
||||
|
||||
await _lessonRepository.DeleteAsync(lesson, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first lesson in a level.
|
||||
/// </summary>
|
||||
public async Task<LessonDto?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonRepository.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
||||
return lesson?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next lesson after the specified one.
|
||||
/// </summary>
|
||||
public async Task<LessonDto?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
|
||||
return lesson?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lessons that the user can access (based on completion of previous lessons).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
||||
return lessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
}
|
||||
101
GermanApp/Application/Services/LevelService.cs
Normal file
101
GermanApp/Application/Services/LevelService.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
|
||||
namespace GermanApp.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application service for managing CEFR levels.
|
||||
/// This is part of the Application layer.
|
||||
/// </summary>
|
||||
public class LevelService
|
||||
{
|
||||
private readonly ILevelRepository _levelRepository;
|
||||
|
||||
public LevelService(ILevelRepository levelRepository)
|
||||
{
|
||||
_levelRepository = levelRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all CEFR levels ordered by their sort order.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LevelDto>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
||||
return levels.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a level by its ID.
|
||||
/// </summary>
|
||||
public async Task<LevelDto?> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
||||
return level?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a level by its code (e.g., "A1", "B2").
|
||||
/// </summary>
|
||||
public async Task<LevelDto?> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelRepository.GetByCodeAsync(code, cancellationToken);
|
||||
return level?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CEFR level.
|
||||
/// </summary>
|
||||
public async Task<LevelDto> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = dto.ToEntity();
|
||||
var createdLevel = await _levelRepository.AddAsync(level, cancellationToken);
|
||||
return createdLevel.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing CEFR level.
|
||||
/// </summary>
|
||||
public async Task<LevelDto?> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (level == null)
|
||||
return null;
|
||||
|
||||
level.UpdateFromDto(dto);
|
||||
await _levelRepository.UpdateAsync(level, cancellationToken);
|
||||
return level.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a CEFR level by its ID.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (level == null)
|
||||
return false;
|
||||
|
||||
await _levelRepository.DeleteAsync(level, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first level (lowest order number) - typically A1.
|
||||
/// </summary>
|
||||
public async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelRepository.GetFirstLevelAsync(cancellationToken);
|
||||
return level?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next level after the specified one.
|
||||
/// </summary>
|
||||
public async Task<LevelDto?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var level = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
|
||||
return level?.ToDto();
|
||||
}
|
||||
}
|
||||
206
GermanApp/Application/Services/ProgressService.cs
Normal file
206
GermanApp/Application/Services/ProgressService.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
|
||||
namespace GermanApp.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application service for managing user progress through lessons.
|
||||
/// This is part of the Application layer.
|
||||
/// </summary>
|
||||
public class ProgressService
|
||||
{
|
||||
private readonly IUserProgressRepository _userProgressRepository;
|
||||
private readonly ILessonRepository _lessonRepository;
|
||||
private readonly ILevelRepository _levelRepository;
|
||||
|
||||
public ProgressService(
|
||||
IUserProgressRepository userProgressRepository,
|
||||
ILessonRepository lessonRepository,
|
||||
ILevelRepository levelRepository)
|
||||
{
|
||||
_userProgressRepository = userProgressRepository;
|
||||
_lessonRepository = lessonRepository;
|
||||
_levelRepository = levelRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user progress for a specific lesson.
|
||||
/// </summary>
|
||||
public async Task<UserProgressDto?> GetUserProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
||||
return progress?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all progress records for a user.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<UserProgressDto>> GetUserProgressAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progressRecords = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
|
||||
return progressRecords.Select(p => p.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets progress for all lessons in a specific level for a user.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<UserProgressDto>> GetUserProgressByLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progressRecords = await _userProgressRepository.GetByUserAndLevelAsync(userId, levelId, cancellationToken);
|
||||
return progressRecords.Select(p => p.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user has completed a lesson.
|
||||
/// </summary>
|
||||
public async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _userProgressRepository.HasUserCompletedLessonAsync(userId, lessonId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a lesson as completed for a user with a quiz score.
|
||||
/// </summary>
|
||||
public async Task<UserProgressDto> MarkLessonAsCompletedAsync(int userId, int lessonId, int quizScore, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
||||
|
||||
if (progress == null)
|
||||
{
|
||||
// Create new progress record
|
||||
progress = UserProgress.Create(userId, lessonId);
|
||||
progress.MarkAsCompleted(quizScore);
|
||||
await _userProgressRepository.AddAsync(progress, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing progress record
|
||||
progress.MarkAsCompleted(quizScore);
|
||||
await _userProgressRepository.UpdateAsync(progress, cancellationToken);
|
||||
}
|
||||
|
||||
return progress.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates user progress for a lesson without marking as completed.
|
||||
/// </summary>
|
||||
public async Task<UserProgressDto> UpdateUserProgressAsync(int userId, UpdateUserProgressDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, dto.LessonId, cancellationToken);
|
||||
|
||||
if (progress == null)
|
||||
{
|
||||
// Create new progress record
|
||||
progress = dto.ToEntity(userId);
|
||||
await _userProgressRepository.AddAsync(progress, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing progress record
|
||||
progress.UpdateFromDto(dto);
|
||||
await _userProgressRepository.UpdateAsync(progress, cancellationToken);
|
||||
}
|
||||
|
||||
return progress.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets user progress for a lesson.
|
||||
/// </summary>
|
||||
public async Task<bool> ResetLessonProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
||||
if (progress == null)
|
||||
return false;
|
||||
|
||||
progress.Reset();
|
||||
await _userProgressRepository.UpdateAsync(progress, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user's average score for a level.
|
||||
/// </summary>
|
||||
public async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _userProgressRepository.GetAverageScoreForLevelAsync(userId, levelId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the percentage of lessons completed in a level.
|
||||
/// </summary>
|
||||
public async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _userProgressRepository.GetLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets level completion summaries for a user.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LevelCompletionDto>> GetLevelCompletionsAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
||||
var completions = new List<LevelCompletionDto>();
|
||||
|
||||
foreach (var level in levels)
|
||||
{
|
||||
var lessons = await _lessonRepository.GetByLevelAsync(level.Id, cancellationToken);
|
||||
var completedCount = 0;
|
||||
|
||||
foreach (var lesson in lessons)
|
||||
{
|
||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(userId, lesson.Id, cancellationToken);
|
||||
if (hasCompleted)
|
||||
completedCount++;
|
||||
}
|
||||
|
||||
var percentage = lessons.Count > 0
|
||||
? (double)completedCount / lessons.Count * 100
|
||||
: 0;
|
||||
|
||||
completions.Add(new LevelCompletionDto(
|
||||
level.Id,
|
||||
level.Name,
|
||||
level.Code,
|
||||
lessons.Count,
|
||||
completedCount,
|
||||
percentage
|
||||
));
|
||||
}
|
||||
|
||||
return completions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lessons that the user can access (completed previous lessons or first in level).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
||||
return lessons.Select(l => l.ToDto()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the next lesson in a level is unlocked for the user.
|
||||
/// </summary>
|
||||
public async Task<bool> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var currentLesson = await _lessonRepository.GetByIdAsync(currentLessonId, cancellationToken);
|
||||
if (currentLesson == null)
|
||||
return false;
|
||||
|
||||
// If this is the first lesson in the level, the next one is always unlocked
|
||||
var firstLesson = await _lessonRepository.GetFirstLessonInLevelAsync(currentLesson.LevelId, cancellationToken);
|
||||
if (firstLesson?.Id == currentLessonId)
|
||||
{
|
||||
var nextLesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
|
||||
return nextLesson != null;
|
||||
}
|
||||
|
||||
// Check if the user has completed the current lesson
|
||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(userId, currentLessonId, cancellationToken);
|
||||
return hasCompleted;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ public class CreateLessonCommandHandler : ICommandHandler<CreateLessonCommand, L
|
|||
var lesson = command.LessonData.ToEntity();
|
||||
|
||||
// Validate business rules (could be extracted to a validator)
|
||||
if (lesson.Level < 1 || lesson.Level > 5)
|
||||
if (lesson.LevelId < 1 || lesson.LevelId > 5)
|
||||
{
|
||||
throw new ValidationException("Level must be between 1 and 5");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
namespace GermanApp.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a German language lesson in the system.
|
||||
/// Represents a lesson in the DeutschLernen system.
|
||||
/// Lessons are organized within CEFR levels and must be completed in order.
|
||||
/// </summary>
|
||||
public class Lesson
|
||||
{
|
||||
// Private setter for domain behavior, but internal for EF Core
|
||||
public int Id { get; private set; }
|
||||
public int LevelId { get; private set; }
|
||||
public string Title { get; private set; } = string.Empty;
|
||||
public int Order { get; private set; }
|
||||
public string Topic { get; private set; } = string.Empty;
|
||||
public string Description { get; private set; } = string.Empty;
|
||||
public int Level { get; private set; }
|
||||
public bool IsActive { get; private set; } = true;
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public DateTime? UpdatedAt { get; private set; }
|
||||
|
||||
// Navigation properties would go here in EF Core
|
||||
// public ICollection<VocabularyWord> Words { get; private set; }
|
||||
// Navigation property (EF Core will handle this)
|
||||
public virtual Level? Level { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for EF Core deserialization.
|
||||
|
|
@ -24,35 +27,84 @@ public class Lesson
|
|||
/// <summary>
|
||||
/// Factory method to create a new lesson.
|
||||
/// </summary>
|
||||
public static Lesson Create(string title, string description, int level)
|
||||
/// <param name="levelId">ID of the parent level</param>
|
||||
/// <param name="title">Title of the lesson</param>
|
||||
/// <param name="order">Sort order within the level</param>
|
||||
/// <param name="topic">Main topic covered in the lesson</param>
|
||||
/// <param name="description">Description of what the lesson covers</param>
|
||||
public static Lesson Create(int levelId, string title, int order, string topic, string description = "")
|
||||
{
|
||||
return new Lesson
|
||||
{
|
||||
LevelId = levelId,
|
||||
Title = title,
|
||||
Order = order,
|
||||
Topic = topic,
|
||||
Description = description,
|
||||
Level = level,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the lesson details.
|
||||
/// Updates the lesson's title.
|
||||
/// </summary>
|
||||
public void Update(string title, string description, int level)
|
||||
public void UpdateTitle(string newTitle)
|
||||
{
|
||||
Title = title;
|
||||
Description = description;
|
||||
Level = level;
|
||||
Title = newTitle;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Domain behavior: Check if lesson is at beginner level.
|
||||
/// Updates the lesson's topic.
|
||||
/// </summary>
|
||||
public bool IsBeginnerLevel() => Level <= 2;
|
||||
public void UpdateTopic(string newTopic)
|
||||
{
|
||||
Topic = newTopic;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Domain behavior: Check if lesson is at advanced level.
|
||||
/// Updates the lesson's description.
|
||||
/// </summary>
|
||||
public bool IsAdvancedLevel() => Level >= 4;
|
||||
public void UpdateDescription(string newDescription)
|
||||
{
|
||||
Description = newDescription;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the lesson's sort order.
|
||||
/// </summary>
|
||||
public void UpdateOrder(int newOrder)
|
||||
{
|
||||
Order = newOrder;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the lesson's level.
|
||||
/// </summary>
|
||||
public void UpdateLevel(int newLevelId)
|
||||
{
|
||||
LevelId = newLevelId;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the lesson.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
IsActive = true;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the lesson.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
IsActive = false;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
GermanApp/Domain/Entities/Level.cs
Normal file
57
GermanApp/Domain/Entities/Level.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
namespace GermanApp.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a CEFR level in the DeutschLernen system (A1, A2, B1, B2, C1).
|
||||
/// </summary>
|
||||
public class Level
|
||||
{
|
||||
public int Id { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public string Code { get; private set; } = string.Empty;
|
||||
public int Order { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for EF Core deserialization.
|
||||
/// </summary>
|
||||
private Level() { }
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a new level.
|
||||
/// </summary>
|
||||
/// <param name="name">Display name of the level (e.g., "Beginner A1")</param>
|
||||
/// <param name="code">Short code for the level (e.g., "A1")</param>
|
||||
/// <param name="order">Sort order of the level</param>
|
||||
public static Level Create(string name, string code, int order)
|
||||
{
|
||||
return new Level
|
||||
{
|
||||
Name = name,
|
||||
Code = code.ToUpperInvariant(),
|
||||
Order = order
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the level's display name.
|
||||
/// </summary>
|
||||
public void UpdateName(string newName)
|
||||
{
|
||||
Name = newName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the level's code.
|
||||
/// </summary>
|
||||
public void UpdateCode(string newCode)
|
||||
{
|
||||
Code = newCode.ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the level's sort order.
|
||||
/// </summary>
|
||||
public void UpdateOrder(int newOrder)
|
||||
{
|
||||
Order = newOrder;
|
||||
}
|
||||
}
|
||||
82
GermanApp/Domain/Entities/UserProgress.cs
Normal file
82
GermanApp/Domain/Entities/UserProgress.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
namespace GermanApp.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user's progress through lessons.
|
||||
/// Tracks completion status and quiz scores.
|
||||
/// </summary>
|
||||
public class UserProgress
|
||||
{
|
||||
public int Id { get; private set; }
|
||||
public int UserId { get; private set; }
|
||||
public int LessonId { get; private set; }
|
||||
public bool IsCompleted { get; private set; }
|
||||
public int QuizScore { get; private set; }
|
||||
public DateTime LastAttemptDate { get; private set; }
|
||||
|
||||
// Navigation properties
|
||||
public virtual User? User { get; private set; }
|
||||
public virtual Lesson? Lesson { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for EF Core deserialization.
|
||||
/// </summary>
|
||||
private UserProgress() { }
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a new user progress record.
|
||||
/// </summary>
|
||||
/// <param name="userId">ID of the user</param>
|
||||
/// <param name="lessonId">ID of the lesson</param>
|
||||
public static UserProgress Create(int userId, int lessonId)
|
||||
{
|
||||
return new UserProgress
|
||||
{
|
||||
UserId = userId,
|
||||
LessonId = lessonId,
|
||||
IsCompleted = false,
|
||||
QuizScore = 0,
|
||||
LastAttemptDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the lesson as completed.
|
||||
/// </summary>
|
||||
/// <param name="quizScore">The score achieved on the quiz (0-100)</param>
|
||||
public void MarkAsCompleted(int quizScore)
|
||||
{
|
||||
if (quizScore < 0 || quizScore > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(quizScore), "Score must be between 0 and 100");
|
||||
|
||||
IsCompleted = true;
|
||||
QuizScore = quizScore;
|
||||
LastAttemptDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the quiz score without marking as completed.
|
||||
/// </summary>
|
||||
public void UpdateQuizScore(int quizScore)
|
||||
{
|
||||
if (quizScore < 0 || quizScore > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(quizScore), "Score must be between 0 and 100");
|
||||
|
||||
QuizScore = quizScore;
|
||||
LastAttemptDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the progress (e.g., when user wants to redo a lesson).
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
IsCompleted = false;
|
||||
QuizScore = 0;
|
||||
LastAttemptDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the user passed the lesson (80% or higher).
|
||||
/// </summary>
|
||||
public bool HasPassed() => QuizScore >= 80;
|
||||
}
|
||||
|
|
@ -41,6 +41,32 @@ public interface IRepository<TEntity, TId> where TEntity : class
|
|||
Task<bool> ExistsAsync(TId id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for Level entities.
|
||||
/// </summary>
|
||||
public interface ILevelRepository : IRepository<Level, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a level by its code (e.g., "A1", "B2").
|
||||
/// </summary>
|
||||
Task<Level?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all levels ordered by their sort order.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Level>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first level (lowest order number).
|
||||
/// </summary>
|
||||
Task<Level?> GetFirstLevelAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next level after the specified one.
|
||||
/// </summary>
|
||||
Task<Level?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for Lesson entities.
|
||||
/// </summary>
|
||||
|
|
@ -49,15 +75,61 @@ public interface ILessonRepository : IRepository<Lesson, int>
|
|||
/// <summary>
|
||||
/// Gets lessons by level.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets beginner-level lessons.
|
||||
/// Gets all lessons ordered by level and lesson order.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets advanced-level lessons.
|
||||
/// Gets the first lesson in a level.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default);
|
||||
Task<Lesson?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next lesson after the specified one (in the same level).
|
||||
/// </summary>
|
||||
Task<Lesson?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets lessons that the user can access (completed previous lessons or first in level).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Lesson>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for UserProgress entities.
|
||||
/// </summary>
|
||||
public interface IUserProgressRepository : IRepository<UserProgress, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets user progress for a specific user and lesson.
|
||||
/// </summary>
|
||||
Task<UserProgress?> GetByUserAndLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all progress records for a user.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<UserProgress>> GetByUserAsync(int userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets progress for all lessons in a specific level for a user.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<UserProgress>> GetByUserAndLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user has completed a lesson (with 80% or higher score).
|
||||
/// </summary>
|
||||
Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user's average score for a level.
|
||||
/// </summary>
|
||||
Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the percentage of lessons completed in a level.
|
||||
/// </summary>
|
||||
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
|||
}
|
||||
|
||||
// DbSets for domain entities
|
||||
public DbSet<Level> Levels { get; set; } = null!;
|
||||
public DbSet<Lesson> Lessons { get; set; } = null!;
|
||||
public DbSet<User> Users { get; set; } = null!;
|
||||
public DbSet<UserProgress> UserProgress { get; set; } = null!;
|
||||
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
||||
|
||||
// Note: Value objects are not stored directly as entities.
|
||||
|
|
@ -25,16 +27,41 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
|||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure Level entity
|
||||
modelBuilder.Entity<Level>(builder =>
|
||||
{
|
||||
builder.HasKey(l => l.Id);
|
||||
builder.Property(l => l.Name).IsRequired().HasMaxLength(100);
|
||||
builder.Property(l => l.Code).IsRequired().HasMaxLength(10);
|
||||
builder.Property(l => l.Order).IsRequired();
|
||||
|
||||
// Ensure unique constraints
|
||||
builder.HasIndex(l => l.Code).IsUnique();
|
||||
builder.HasIndex(l => l.Order).IsUnique();
|
||||
});
|
||||
|
||||
// Configure Lesson entity
|
||||
modelBuilder.Entity<Lesson>(builder =>
|
||||
{
|
||||
builder.HasKey(l => l.Id);
|
||||
builder.Property(l => l.Title).IsRequired().HasMaxLength(200);
|
||||
builder.Property(l => l.Description).IsRequired().HasMaxLength(2000);
|
||||
builder.Property(l => l.Level).IsRequired();
|
||||
builder.Property(l => l.Description).HasMaxLength(2000);
|
||||
builder.Property(l => l.Topic).IsRequired().HasMaxLength(100);
|
||||
builder.Property(l => l.LevelId).IsRequired();
|
||||
builder.Property(l => l.Order).IsRequired();
|
||||
builder.Property(l => l.IsActive).HasDefaultValue(true);
|
||||
builder.Property(l => l.CreatedAt).IsRequired();
|
||||
builder.Property(l => l.UpdatedAt).IsRequired(false);
|
||||
|
||||
// Foreign key to Level with unique constraint per level
|
||||
builder.HasOne(l => l.Level)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.LevelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Unique constraint: one lesson per level per order
|
||||
builder.HasIndex(l => new { l.LevelId, l.Order }).IsUnique();
|
||||
|
||||
// Value object: GermanWord would be configured as an owned entity
|
||||
// builder.OwnsMany(l => l.Words, wordBuilder =>
|
||||
// {
|
||||
|
|
@ -45,6 +72,31 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
|||
// });
|
||||
});
|
||||
|
||||
// Configure UserProgress entity
|
||||
modelBuilder.Entity<UserProgress>(builder =>
|
||||
{
|
||||
builder.HasKey(up => up.Id);
|
||||
builder.Property(up => up.UserId).IsRequired();
|
||||
builder.Property(up => up.LessonId).IsRequired();
|
||||
builder.Property(up => up.IsCompleted).HasDefaultValue(false);
|
||||
builder.Property(up => up.QuizScore).HasDefaultValue(0);
|
||||
builder.Property(up => up.LastAttemptDate).IsRequired();
|
||||
|
||||
// Foreign keys
|
||||
builder.HasOne(up => up.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(up => up.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(up => up.Lesson)
|
||||
.WithMany()
|
||||
.HasForeignKey(up => up.LessonId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Unique constraint: one progress record per user per lesson
|
||||
builder.HasIndex(up => new { up.UserId, up.LessonId }).IsUnique();
|
||||
});
|
||||
|
||||
// Configure User entity
|
||||
modelBuilder.Entity<User>(builder =>
|
||||
{
|
||||
|
|
|
|||
277
GermanApp/Infrastructure/Data/Migrations/20260607192743_AddLessonManagementTables.Designer.cs
generated
Normal file
277
GermanApp/Infrastructure/Data/Migrations/20260607192743_AddLessonManagementTables.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
// <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("20260607192743_AddLessonManagementTables")]
|
||||
partial class AddLessonManagementTables
|
||||
{
|
||||
/// <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.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.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");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GermanApp.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLessonManagementTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "Level",
|
||||
table: "Lessons",
|
||||
newName: "Order");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsActive",
|
||||
table: "Lessons",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LevelId",
|
||||
table: "Lessons",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Topic",
|
||||
table: "Lessons",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Levels",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Levels", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserProgress",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||
LessonId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsCompleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
QuizScore = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||
LastAttemptDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserProgress", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserProgress_Lessons_LessonId",
|
||||
column: x => x.LessonId,
|
||||
principalTable: "Lessons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserProgress_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Lessons_LevelId_Order",
|
||||
table: "Lessons",
|
||||
columns: new[] { "LevelId", "Order" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Levels_Code",
|
||||
table: "Levels",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Levels_Order",
|
||||
table: "Levels",
|
||||
column: "Order",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserProgress_LessonId",
|
||||
table: "UserProgress",
|
||||
column: "LessonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserProgress_UserId_LessonId",
|
||||
table: "UserProgress",
|
||||
columns: new[] { "UserId", "LessonId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Lessons_Levels_LevelId",
|
||||
table: "Lessons",
|
||||
column: "LevelId",
|
||||
principalTable: "Levels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Lessons_Levels_LevelId",
|
||||
table: "Lessons");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Levels");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserProgress");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Lessons_LevelId_Order",
|
||||
table: "Lessons");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsActive",
|
||||
table: "Lessons");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LevelId",
|
||||
table: "Lessons");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Topic",
|
||||
table: "Lessons");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "Order",
|
||||
table: "Lessons",
|
||||
newName: "Level");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,15 @@ namespace GermanApp.Migrations
|
|||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("Level")
|
||||
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")
|
||||
|
|
@ -46,14 +54,54 @@ namespace GermanApp.Migrations
|
|||
.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.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -145,6 +193,54 @@ namespace GermanApp.Migrations
|
|||
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.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||
|
|
@ -153,6 +249,25 @@ namespace GermanApp.Migrations
|
|||
.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");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ public class LessonRepository : ILessonRepository
|
|||
public async Task<Lesson?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Lessons
|
||||
.Include(l => l.Level)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Lesson>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Lessons
|
||||
.Include(l => l.Level)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
|
@ -56,27 +58,104 @@ public class LessonRepository : ILessonRepository
|
|||
.AnyAsync(l => l.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Lessons
|
||||
.Where(l => l.Level == level)
|
||||
.Include(l => l.Level)
|
||||
.Where(l => l.LevelId == levelId)
|
||||
.OrderBy(l => l.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Lessons
|
||||
.Where(l => l.Level <= 2)
|
||||
.Include(l => l.Level)
|
||||
.OrderBy(l => l.Level.Order)
|
||||
.ThenBy(l => l.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<Lesson?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Lessons
|
||||
.Where(l => l.Level >= 4)
|
||||
.Include(l => l.Level)
|
||||
.Where(l => l.LevelId == levelId)
|
||||
.OrderBy(l => l.Order)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Lesson?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var currentLesson = await _context.Lessons
|
||||
.Where(l => l.Id == currentLessonId)
|
||||
.Select(l => new { l.LevelId, l.Order })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (currentLesson == null)
|
||||
return null;
|
||||
|
||||
return await _context.Lessons
|
||||
.Include(l => l.Level)
|
||||
.Where(l => l.LevelId == currentLesson.LevelId && l.Order > currentLesson.Order)
|
||||
.OrderBy(l => l.Order)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Lesson>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get all lessons ordered by level and lesson order
|
||||
var allLessons = await _context.Lessons
|
||||
.Include(l => l.Level)
|
||||
.OrderBy(l => l.Level.Order)
|
||||
.ThenBy(l => l.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allLessons.Count == 0)
|
||||
return new List<Lesson>();
|
||||
|
||||
// Get completed lessons for this user
|
||||
var completedLessonIds = await _context.UserProgress
|
||||
.Where(up => up.UserId == userId && up.IsCompleted)
|
||||
.Select(up => up.LessonId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Find the first lesson in the first level
|
||||
var firstLesson = allLessons.First();
|
||||
|
||||
// If user hasn't completed any lessons, they can only access the first one
|
||||
if (completedLessonIds.Count == 0)
|
||||
return new List<Lesson> { firstLesson };
|
||||
|
||||
// Get the highest ordered lesson that the user has completed
|
||||
var completedLessons = allLessons
|
||||
.Where(l => completedLessonIds.Contains(l.Id))
|
||||
.OrderBy(l => l.Level.Order)
|
||||
.ThenBy(l => l.Order)
|
||||
.ToList();
|
||||
|
||||
if (completedLessons.Count == 0)
|
||||
return new List<Lesson> { firstLesson };
|
||||
|
||||
var lastCompleted = completedLessons.Last();
|
||||
|
||||
// User can access all lessons up to and including the next one after the last completed
|
||||
var accessibleLessons = allLessons
|
||||
.TakeWhile(l => l.Id != lastCompleted.Id)
|
||||
.ToList();
|
||||
|
||||
// Add the last completed and the next one
|
||||
accessibleLessons.Add(lastCompleted);
|
||||
|
||||
var nextLessonIndex = allLessons.FindIndex(l => l.Id == lastCompleted.Id) + 1;
|
||||
if (nextLessonIndex < allLessons.Count)
|
||||
{
|
||||
accessibleLessons.Add(allLessons[nextLessonIndex]);
|
||||
}
|
||||
|
||||
return accessibleLessons;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
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 ILevelRepository.
|
||||
/// This is part of the Infrastructure layer.
|
||||
/// </summary>
|
||||
public class LevelRepository : ILevelRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public LevelRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Level?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Levels
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Level>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Levels
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Level> AddAsync(Level entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Levels.AddAsync(entity, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Level entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Levels.Update(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Level entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Levels.Remove(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Levels
|
||||
.AnyAsync(l => l.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Level?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Levels
|
||||
.FirstOrDefaultAsync(l => l.Code == code.ToUpperInvariant(), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Level>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Levels
|
||||
.OrderBy(l => l.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Level?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Levels
|
||||
.OrderBy(l => l.Order)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Level?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var currentLevel = await _context.Levels
|
||||
.Where(l => l.Id == currentLevelId)
|
||||
.Select(l => l.Order)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (currentLevel == default)
|
||||
return null;
|
||||
|
||||
return await _context.Levels
|
||||
.Where(l => l.Order > currentLevel)
|
||||
.OrderBy(l => l.Order)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
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 IUserProgressRepository.
|
||||
/// This is part of the Infrastructure layer.
|
||||
/// </summary>
|
||||
public class UserProgressRepository : IUserProgressRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public UserProgressRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<UserProgress?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.Include(up => up.User)
|
||||
.Include(up => up.Lesson)
|
||||
.FirstOrDefaultAsync(up => up.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserProgress>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.Include(up => up.User)
|
||||
.Include(up => up.Lesson)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserProgress> AddAsync(UserProgress entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.UserProgress.AddAsync(entity, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(UserProgress entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.UserProgress.Update(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(UserProgress entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.UserProgress.Remove(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.AnyAsync(up => up.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserProgress?> GetByUserAndLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.Include(up => up.User)
|
||||
.Include(up => up.Lesson)
|
||||
.FirstOrDefaultAsync(up => up.UserId == userId && up.LessonId == lessonId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserProgress>> GetByUserAsync(int userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.Include(up => up.Lesson)
|
||||
.ThenInclude(l => l.Level)
|
||||
.Where(up => up.UserId == userId)
|
||||
.OrderBy(up => up.Lesson.Level!.Order)
|
||||
.ThenBy(up => up.Lesson.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserProgress>> GetByUserAndLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.Include(up => up.Lesson)
|
||||
.Where(up => up.UserId == userId && up.Lesson.LevelId == levelId)
|
||||
.OrderBy(up => up.Lesson.Order)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserProgress
|
||||
.AnyAsync(up => up.UserId == userId && up.LessonId == lessonId && up.IsCompleted, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var progresses = await _context.UserProgress
|
||||
.Where(up => up.UserId == userId && up.Lesson.LevelId == levelId && up.QuizScore > 0)
|
||||
.Select(up => up.QuizScore)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (progresses.Count == 0)
|
||||
return 0;
|
||||
|
||||
return progresses.Average();
|
||||
}
|
||||
|
||||
public async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get total lessons in the level
|
||||
var totalLessons = await _context.Lessons
|
||||
.CountAsync(l => l.LevelId == levelId, cancellationToken);
|
||||
|
||||
if (totalLessons == 0)
|
||||
return 0;
|
||||
|
||||
// Get completed lessons count for user in this level
|
||||
var completedCount = await _context.UserProgress
|
||||
.CountAsync(up => up.UserId == userId && up.Lesson.LevelId == levelId && up.IsCompleted, cancellationToken);
|
||||
|
||||
return (double)completedCount / totalLessons * 100;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ public static class SeedDataExtension
|
|||
/// Seeds the database with initial data.
|
||||
/// </summary>
|
||||
/// <param name="app">The web application</param>
|
||||
public static void SeedDatabase(this WebApplication app)
|
||||
public static async Task SeedDatabaseAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
|
@ -24,6 +24,18 @@ public static class SeedDataExtension
|
|||
// Only seed if there are no users
|
||||
if (!dbContext.Users.Any())
|
||||
{
|
||||
// Seed CEFR levels first
|
||||
var levels = new[]
|
||||
{
|
||||
Level.Create("Beginner A1", "A1", 1),
|
||||
Level.Create("Elementary A2", "A2", 2),
|
||||
Level.Create("Intermediate B1", "B1", 3),
|
||||
Level.Create("Upper Intermediate B2", "B2", 4),
|
||||
Level.Create("Advanced C1", "C1", 5)
|
||||
};
|
||||
dbContext.Levels.AddRange(levels);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Seed an admin user
|
||||
var adminUser = User.Create(
|
||||
"admin",
|
||||
|
|
@ -44,17 +56,17 @@ public static class SeedDataExtension
|
|||
// Seed some lessons
|
||||
var lessons = new[]
|
||||
{
|
||||
Lesson.Create("Greetings", "Basic German greetings and introductions", 1),
|
||||
Lesson.Create("Numbers", "German numbers 1-100", 1),
|
||||
Lesson.Create("Grammar Basics", "Basic German grammar rules", 2),
|
||||
Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2),
|
||||
Lesson.Create("Advanced Grammar", "Complex German grammar", 4)
|
||||
Lesson.Create(1, "Greetings", 1, "Greetings", "Basic German greetings and introductions"),
|
||||
Lesson.Create(1, "Numbers", 2, "Numbers", "German numbers 1-100"),
|
||||
Lesson.Create(2, "Grammar Basics", 1, "Grammar", "Basic German grammar rules"),
|
||||
Lesson.Create(2, "Everyday Phrases", 2, "Phrases", "Common phrases for daily conversations"),
|
||||
Lesson.Create(5, "Advanced Grammar", 1, "Grammar", "Complex German grammar")
|
||||
};
|
||||
dbContext.Lessons.AddRange(lessons);
|
||||
|
||||
dbContext.SaveChanges();
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Database seeded with admin user, test user, and sample lessons.");
|
||||
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Application.UseCases.Commands;
|
||||
using GermanApp.Domain.Entities;
|
||||
using GermanApp.Domain.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
@ -45,8 +46,13 @@ public static class LessonsEndpoints
|
|||
// GET /api/lessons/beginner
|
||||
group.MapGet("/beginner", async ([FromServices] ILessonRepository repository) =>
|
||||
{
|
||||
var lessons = await repository.GetBeginnerLessonsAsync();
|
||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
||||
// Beginner lessons are A1 (level 1) and A2 (level 2)
|
||||
var beginnerLessons = new List<Lesson>();
|
||||
var level1Lessons = await repository.GetByLevelAsync(1);
|
||||
var level2Lessons = await repository.GetByLevelAsync(2);
|
||||
beginnerLessons.AddRange(level1Lessons);
|
||||
beginnerLessons.AddRange(level2Lessons);
|
||||
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
||||
})
|
||||
.WithName("GetBeginnerLessons")
|
||||
.WithOpenApi(operation => new(operation)
|
||||
|
|
@ -58,8 +64,13 @@ public static class LessonsEndpoints
|
|||
// GET /api/lessons/advanced
|
||||
group.MapGet("/advanced", async ([FromServices] ILessonRepository repository) =>
|
||||
{
|
||||
var lessons = await repository.GetAdvancedLessonsAsync();
|
||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
||||
// Advanced lessons are B2 (level 4) and C1 (level 5)
|
||||
var advancedLessons = new List<Lesson>();
|
||||
var level4Lessons = await repository.GetByLevelAsync(4);
|
||||
var level5Lessons = await repository.GetByLevelAsync(5);
|
||||
advancedLessons.AddRange(level4Lessons);
|
||||
advancedLessons.AddRange(level5Lessons);
|
||||
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
||||
})
|
||||
.WithName("GetAdvancedLessons")
|
||||
.WithOpenApi(operation => new(operation)
|
||||
|
|
@ -110,7 +121,7 @@ public static class LessonsEndpoints
|
|||
return Results.NotFound();
|
||||
|
||||
// Map DTO to entity
|
||||
existingLesson.Update(dto.Title, dto.Description, dto.Level);
|
||||
existingLesson.UpdateFromDto(dto);
|
||||
await repository.UpdateAsync(existingLesson);
|
||||
|
||||
return Results.Ok(existingLesson.ToDto());
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using GermanApp.Application.DTOs;
|
||||
using GermanApp.Application.Services;
|
||||
using GermanApp.Application.Interfaces;
|
||||
using GermanApp.Application.UseCases.Commands;
|
||||
using GermanApp.Domain.Entities;
|
||||
|
|
@ -115,13 +116,20 @@ try
|
|||
});
|
||||
|
||||
// Register repositories (Infrastructure implementations of Domain interfaces)
|
||||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||
|
||||
// ============================================
|
||||
// APPLICATION LAYER - Use Cases & Services
|
||||
// ============================================
|
||||
|
||||
// Register command handlers
|
||||
|
||||
// Register application services
|
||||
builder.Services.AddScoped<LevelService>();
|
||||
builder.Services.AddScoped<LessonService>();
|
||||
builder.Services.AddScoped<ProgressService>();
|
||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -153,12 +161,15 @@ try
|
|||
name: "api",
|
||||
pattern: "api/{controller}/{action}/{id?}" );
|
||||
|
||||
// Seed database with initial data
|
||||
app.SeedDatabase();
|
||||
|
||||
// Map Clean Architecture endpoints
|
||||
app.MapLessonsEndpoints();
|
||||
|
||||
// Seed database with initial data
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await app.SeedDatabaseAsync();
|
||||
}
|
||||
|
||||
// Keep original WeatherForecast endpoint for reference
|
||||
app.MapGet("/weatherforecast", () =>
|
||||
{
|
||||
|
|
|
|||
161
Tests/Unit/Application/Services/LessonServiceTests.cs
Normal file
161
Tests/Unit/Application/Services/LessonServiceTests.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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 LessonServiceTests
|
||||
{
|
||||
private Mock<ILessonRepository> _mockLessonRepo;
|
||||
private Mock<ILevelRepository> _mockLevelRepo;
|
||||
private LessonService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_mockLessonRepo = new Mock<ILessonRepository>();
|
||||
_mockLevelRepo = new Mock<ILevelRepository>();
|
||||
_service = new LessonService(_mockLessonRepo.Object, _mockLevelRepo.Object);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetAllLessonsAsync_ReturnsMappedDtoList()
|
||||
{
|
||||
var lessons = new List<Lesson> { Lesson.Create(1, "Test", 1, "Topic", "Desc") };
|
||||
_mockLessonRepo.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lessons);
|
||||
|
||||
var result = await _service.GetAllLessonsAsync();
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("Test", result[0].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLessonByIdAsync_WithExistingId_ReturnsLesson()
|
||||
{
|
||||
var lesson = Lesson.Create(1, "Test", 1, "Topic", "Desc");
|
||||
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lesson);
|
||||
|
||||
var result = await _service.GetLessonByIdAsync(1);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Test", result.Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLessonByIdAsync_WithNonExistingId_ReturnsNull()
|
||||
{
|
||||
_mockLessonRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Lesson?)null);
|
||||
|
||||
var result = await _service.GetLessonByIdAsync(999);
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLessonsByLevelAsync_ReturnsFilteredLessons()
|
||||
{
|
||||
var lessons = new List<Lesson> { Lesson.Create(1, "L1", 1, "T1", "D1") };
|
||||
_mockLessonRepo.Setup(r => r.GetByLevelAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lessons);
|
||||
|
||||
var result = await _service.GetLessonsByLevelAsync(1);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("L1", result[0].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CreateLessonAsync_WithValidDtoAndValidLevel_CreatesLesson()
|
||||
{
|
||||
var level = Level.Create("A1", "A1", 1);
|
||||
var dto = new CreateLessonDto("New", "Desc", 1, 1, "Topic");
|
||||
var lesson = Lesson.Create(1, "New", 1, "Topic", "Desc");
|
||||
|
||||
_mockLevelRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level);
|
||||
_mockLessonRepo.Setup(r => r.AddAsync(It.IsAny<Lesson>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lesson);
|
||||
|
||||
var result = await _service.CreateLessonAsync(dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("New", result.Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UpdateLessonAsync_WithExistingLesson_UpdatesLesson()
|
||||
{
|
||||
var existing = Lesson.Create(1, "Old", 1, "OldT", "OldD");
|
||||
var level = Level.Create("A1", "A1", 1);
|
||||
var dto = new UpdateLessonDto("New", "NewD", 1, 1, "NewT");
|
||||
|
||||
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing);
|
||||
_mockLevelRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level);
|
||||
_mockLessonRepo.Setup(r => r.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.UpdateLessonAsync(1, dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DeleteLessonAsync_WithExistingLesson_ReturnsTrue()
|
||||
{
|
||||
var lesson = Lesson.Create(1, "Test", 1, "Topic", "Desc");
|
||||
_mockLessonRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lesson);
|
||||
_mockLessonRepo.Setup(r => r.DeleteAsync(lesson, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.DeleteLessonAsync(1);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetBeginnerLessonsAsync_ReturnsA1AndA2Lessons()
|
||||
{
|
||||
var level1 = new List<Lesson> { Lesson.Create(1, "A1L1", 1, "T1", "D1") };
|
||||
var level2 = new List<Lesson> { Lesson.Create(2, "A2L1", 1, "T2", "D2") };
|
||||
|
||||
_mockLessonRepo.Setup(r => r.GetByLevelAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level1);
|
||||
_mockLessonRepo.Setup(r => r.GetByLevelAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level2);
|
||||
|
||||
var result = await _service.GetBeginnerLessonsAsync();
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetAdvancedLessonsAsync_ReturnsB2AndC1Lessons()
|
||||
{
|
||||
var level4 = new List<Lesson> { Lesson.Create(4, "B2L1", 1, "T4", "D4") };
|
||||
var level5 = new List<Lesson> { Lesson.Create(5, "C1L1", 1, "T5", "D5") };
|
||||
|
||||
_mockLessonRepo.Setup(r => r.GetByLevelAsync(4, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level4);
|
||||
_mockLessonRepo.Setup(r => r.GetByLevelAsync(5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level5);
|
||||
|
||||
var result = await _service.GetAdvancedLessonsAsync();
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
}
|
||||
}
|
||||
143
Tests/Unit/Application/Services/LevelServiceTests.cs
Normal file
143
Tests/Unit/Application/Services/LevelServiceTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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 LevelServiceTests
|
||||
{
|
||||
private Mock<ILevelRepository> _mockRepo;
|
||||
private LevelService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_mockRepo = new Mock<ILevelRepository>();
|
||||
_service = new LevelService(_mockRepo.Object);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetAllLevelsAsync_ReturnsMappedDtoList()
|
||||
{
|
||||
var levels = new List<Level> { Level.Create("A1", "A1", 1) };
|
||||
_mockRepo.Setup(r => r.GetAllOrderedAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(levels);
|
||||
|
||||
var result = await _service.GetAllLevelsAsync();
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual("A1", result[0].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLevelByIdAsync_WithExistingId_ReturnsLevel()
|
||||
{
|
||||
var level = Level.Create("A1", "A1", 1);
|
||||
_mockRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level);
|
||||
|
||||
var result = await _service.GetLevelByIdAsync(1);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("A1", result.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLevelByIdAsync_WithNonExistingId_ReturnsNull()
|
||||
{
|
||||
_mockRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Level?)null);
|
||||
|
||||
var result = await _service.GetLevelByIdAsync(999);
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLevelByCodeAsync_WithExistingCode_ReturnsLevel()
|
||||
{
|
||||
var level = Level.Create("A1", "A1", 1);
|
||||
_mockRepo.Setup(r => r.GetByCodeAsync("A1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level);
|
||||
|
||||
var result = await _service.GetLevelByCodeAsync("A1");
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("A1", result.Code);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CreateLevelAsync_WithValidDto_CallsAddAsync()
|
||||
{
|
||||
var dto = new CreateLevelDto("Test", "T1", 1);
|
||||
var level = Level.Create("Test", "T1", 1);
|
||||
_mockRepo.Setup(r => r.AddAsync(It.IsAny<Level>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level);
|
||||
|
||||
var result = await _service.CreateLevelAsync(dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
_mockRepo.Verify(r => r.AddAsync(It.IsAny<Level>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UpdateLevelAsync_WithExistingLevel_CallsUpdateAsync()
|
||||
{
|
||||
var existing = Level.Create("A1", "A1", 1);
|
||||
var dto = new UpdateLevelDto("New", "N1", 2);
|
||||
_mockRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing);
|
||||
_mockRepo.Setup(r => r.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.UpdateLevelAsync(1, dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
_mockRepo.Verify(r => r.UpdateAsync(existing, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UpdateLevelAsync_WithNonExistingLevel_ReturnsNull()
|
||||
{
|
||||
var dto = new UpdateLevelDto("New", "N1", 2);
|
||||
_mockRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Level?)null);
|
||||
|
||||
var result = await _service.UpdateLevelAsync(999, dto);
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DeleteLevelAsync_WithExistingLevel_ReturnsTrue()
|
||||
{
|
||||
var level = Level.Create("A1", "A1", 1);
|
||||
_mockRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(level);
|
||||
_mockRepo.Setup(r => r.DeleteAsync(level, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.DeleteLevelAsync(1);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DeleteLevelAsync_WithNonExistingLevel_ReturnsFalse()
|
||||
{
|
||||
_mockRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Level?)null);
|
||||
|
||||
var result = await _service.DeleteLevelAsync(999);
|
||||
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
}
|
||||
167
Tests/Unit/Application/Services/ProgressServiceTests.cs
Normal file
167
Tests/Unit/Application/Services/ProgressServiceTests.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using System;
|
||||
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 ProgressServiceTests
|
||||
{
|
||||
private Mock<IUserProgressRepository> _mockProgressRepo;
|
||||
private Mock<ILessonRepository> _mockLessonRepo;
|
||||
private Mock<ILevelRepository> _mockLevelRepo;
|
||||
private ProgressService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_mockProgressRepo = new Mock<IUserProgressRepository>();
|
||||
_mockLessonRepo = new Mock<ILessonRepository>();
|
||||
_mockLevelRepo = new Mock<ILevelRepository>();
|
||||
_service = new ProgressService(
|
||||
_mockProgressRepo.Object,
|
||||
_mockLessonRepo.Object,
|
||||
_mockLevelRepo.Object);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetUserProgressAsync_WithExistingProgress_ReturnsDto()
|
||||
{
|
||||
var progress = UserProgress.Create(1, 1);
|
||||
progress.MarkAsCompleted(85);
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(progress);
|
||||
|
||||
var result = await _service.GetUserProgressAsync(1, 1);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.IsCompleted);
|
||||
Assert.AreEqual(85, result.QuizScore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetUserProgressAsync_WithNoProgress_ReturnsNull()
|
||||
{
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserProgress?)null);
|
||||
|
||||
var result = await _service.GetUserProgressAsync(1, 999);
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task HasUserCompletedLessonAsync_ReturnsRepositoryResult()
|
||||
{
|
||||
_mockProgressRepo.Setup(r => r.HasUserCompletedLessonAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var result = await _service.HasUserCompletedLessonAsync(1, 1);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkLessonAsCompletedAsync_WithNewProgress_CreatesProgress()
|
||||
{
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserProgress?)null);
|
||||
var newProgress = UserProgress.Create(1, 1);
|
||||
newProgress.MarkAsCompleted(90);
|
||||
_mockProgressRepo.Setup(r => r.AddAsync(It.IsAny<UserProgress>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(newProgress);
|
||||
|
||||
var result = await _service.MarkLessonAsCompletedAsync(1, 1, 90);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.IsCompleted);
|
||||
Assert.AreEqual(90, result.QuizScore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkLessonAsCompletedAsync_WithExistingProgress_UpdatesProgress()
|
||||
{
|
||||
var existingProgress = UserProgress.Create(1, 1);
|
||||
existingProgress.UpdateQuizScore(50);
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingProgress);
|
||||
_mockProgressRepo.Setup(r => r.UpdateAsync(existingProgress, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.MarkLessonAsCompletedAsync(1, 1, 85);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.IsCompleted);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UpdateUserProgressAsync_WithNewProgress_CreatesProgress()
|
||||
{
|
||||
var dto = new UpdateUserProgressDto(1, false, 60);
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserProgress?)null);
|
||||
var newProgress = UserProgress.Create(1, 1);
|
||||
newProgress.UpdateQuizScore(60);
|
||||
_mockProgressRepo.Setup(r => r.AddAsync(It.IsAny<UserProgress>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(newProgress);
|
||||
|
||||
var result = await _service.UpdateUserProgressAsync(1, dto);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(60, result.QuizScore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ResetLessonProgressAsync_WithExistingProgress_ReturnsTrue()
|
||||
{
|
||||
var progress = UserProgress.Create(1, 1);
|
||||
progress.MarkAsCompleted(90);
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(progress);
|
||||
_mockProgressRepo.Setup(r => r.UpdateAsync(progress, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.ResetLessonProgressAsync(1, 1);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ResetLessonProgressAsync_WithNoProgress_ReturnsFalse()
|
||||
{
|
||||
_mockProgressRepo.Setup(r => r.GetByUserAndLessonAsync(1, 999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserProgress?)null);
|
||||
|
||||
var result = await _service.ResetLessonProgressAsync(1, 999);
|
||||
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetAverageScoreForLevelAsync_ReturnsRepositoryResult()
|
||||
{
|
||||
_mockProgressRepo.Setup(r => r.GetAverageScoreForLevelAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(75.5);
|
||||
|
||||
var result = await _service.GetAverageScoreForLevelAsync(1, 1);
|
||||
|
||||
Assert.AreEqual(75.5, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GetLevelCompletionPercentageAsync_ReturnsRepositoryResult()
|
||||
{
|
||||
_mockProgressRepo.Setup(r => r.GetLevelCompletionPercentageAsync(1, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(60.0);
|
||||
|
||||
var result = await _service.GetLevelCompletionPercentageAsync(1, 1);
|
||||
|
||||
Assert.AreEqual(60.0, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ Implement the core backend functionality, including lesson management, AI servic
|
|||
### Features
|
||||
| # | Feature | Description | Hours | Status | Dependencies |
|
||||
|---|---------|-------------|-------|--------|--------------|
|
||||
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | ⏳ Planned | Phase 1 |
|
||||
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | 🚀 In Progress (Phase 1 & 2 ✅) | Phase 1 |
|
||||
| 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | ⏳ Planned | Phase 1 |
|
||||
| 2.3 | [Vocabulary System](features/vocabulary-system.md) | Word storage, audio, import | 8-12h | ⏳ Planned | Phase 1, 2.1 |
|
||||
| 2.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 |
|
||||
|
|
@ -265,8 +265,8 @@ Implement the complete React + TypeScript frontend application with all UI compo
|
|||
|
||||
| Phase | Duration | Hours | Features | Status |
|
||||
|-------|----------|-------|----------|--------|
|
||||
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | 🚀 In Progress (1.1 ✅, 1.2 🚀) |
|
||||
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned |
|
||||
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | ✅ Complete (1.1 ✅, 1.2 ✅) |
|
||||
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | 🚀 In Progress (2.1 🚀) |
|
||||
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
|
||||
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
|
||||
| **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Feature: Lesson & Content Management
|
||||
|
||||
> **Status**: ⏳ Planned
|
||||
> **Status**: 🚀 In Progress
|
||||
> **Priority**: High
|
||||
> **Complexity**: High
|
||||
> **Estimate**: 10-16 hours
|
||||
|
|
@ -10,6 +10,12 @@
|
|||
> **PR**: -
|
||||
> **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration
|
||||
|
||||
**Phase 1: Database & Models - COMPLETED ✅**
|
||||
|
||||
**Phase 2: Backend Services - COMPLETED ✅**
|
||||
|
||||
**All Phase 1 & Phase 2 tasks are complete. Ready for Phase 3: API Controllers.**
|
||||
|
||||
---
|
||||
|
||||
## 📌 Overview
|
||||
|
|
@ -132,20 +138,20 @@ Each lesson contains:
|
|||
## 🚀 Implementation Plan
|
||||
|
||||
### Phase 1: Database & Models (2-3 hours)
|
||||
- [ ] Create Level entity and repository
|
||||
- [ ] Create Lesson entity and repository
|
||||
- [ ] Create UserProgress entity and repository
|
||||
- [ ] Set up relationships between entities
|
||||
- [ ] Create migrations for Levels, Lessons, UserProgress
|
||||
- [ ] Seed initial A1 level and lessons
|
||||
- [x] Create Level entity and repository
|
||||
- [x] Create Lesson entity and repository
|
||||
- [x] Create UserProgress entity and repository
|
||||
- [x] Set up relationships between entities
|
||||
- [x] Create migrations for Levels, Lessons, UserProgress
|
||||
- [x] Seed initial A1 level and lessons
|
||||
|
||||
### Phase 2: Backend Services (3-5 hours)
|
||||
- [ ] Create LevelService with CRUD operations
|
||||
- [ ] Create LessonService with CRUD operations
|
||||
- [ ] Create ProgressService to track user progress
|
||||
- [ ] Implement sequential unlocking logic
|
||||
- [ ] Create DTOs for Level, Lesson, UserProgress
|
||||
- [ ] Create mapping profiles (AutoMapper or manual)
|
||||
- [x] Create LevelService with CRUD operations
|
||||
- [x] Create LessonService with CRUD operations
|
||||
- [x] Create ProgressService to track user progress
|
||||
- [x] Implement sequential unlocking logic
|
||||
- [x] Create DTOs for Level, Lesson, UserProgress
|
||||
- [x] Create mapping profiles (manual extension methods)
|
||||
|
||||
### Phase 3: API Controllers (2-3 hours)
|
||||
- [ ] Create LevelsController
|
||||
|
|
@ -180,17 +186,21 @@ Each lesson contains:
|
|||
## ✅ Tasks
|
||||
|
||||
### Backend
|
||||
- [ ] Create Domain/Entities/Level.cs
|
||||
- [ ] Create Domain/Entities/Lesson.cs
|
||||
- [ ] Create Domain/Entities/UserProgress.cs
|
||||
- [ ] Create Domain/Interfaces/ILevelRepository.cs
|
||||
- [ ] Create Domain/Interfaces/ILessonRepository.cs
|
||||
- [ ] Create Infrastructure/Data/Repositories/LevelRepository.cs
|
||||
- [ ] Create Infrastructure/Data/Repositories/LessonRepository.cs
|
||||
- [ ] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
|
||||
- [ ] Create Application/DTOs/LevelDto.cs
|
||||
- [ ] Create Application/DTOs/LessonDto.cs
|
||||
- [ ] Create Application/DTOs/UserProgressDto.cs
|
||||
- [x] Create Domain/Entities/Level.cs
|
||||
- [x] Create Domain/Entities/Lesson.cs
|
||||
- [x] Create Domain/Entities/UserProgress.cs
|
||||
- [x] Create Domain/Interfaces/ILevelRepository.cs
|
||||
- [x] Create Domain/Interfaces/ILessonRepository.cs
|
||||
- [x] Create Infrastructure/Data/Repositories/LevelRepository.cs
|
||||
- [x] Create Infrastructure/Data/Repositories/LessonRepository.cs
|
||||
- [x] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
|
||||
- [x] Create Application/DTOs/LevelDto.cs
|
||||
- [x] Create Application/DTOs/LessonDto.cs
|
||||
- [x] Create Application/DTOs/UserProgressDto.cs
|
||||
- [x] Create Application/Services/LevelService.cs
|
||||
- [x] Create Application/Services/LessonService.cs
|
||||
- [x] Create Application/Services/ProgressService.cs
|
||||
- [x] Write unit tests for services (30 tests)
|
||||
- [ ] Create Application/Services/LevelService.cs
|
||||
- [ ] Create Application/Services/LessonService.cs
|
||||
- [ ] Create Application/Services/ProgressService.cs
|
||||
|
|
@ -201,11 +211,11 @@ Each lesson contains:
|
|||
- [ ] Write integration tests for controllers
|
||||
|
||||
### Database
|
||||
- [ ] Create migration for Levels table
|
||||
- [ ] Create migration for Lessons table
|
||||
- [ ] Create migration for UserProgress table
|
||||
- [ ] Seed A1 level with initial lessons
|
||||
- [ ] Add indexes for performance
|
||||
- [x] Create migration for Levels table
|
||||
- [x] Create migration for Lessons table
|
||||
- [x] Create migration for UserProgress table
|
||||
- [x] Seed A1 level with initial lessons
|
||||
- [x] Add indexes for performance
|
||||
|
||||
### Business Logic
|
||||
- [ ] Implement LessonUnlockService
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue