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,
|
int Id,
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
int Level,
|
int LevelId,
|
||||||
string LevelDescription,
|
string LevelName,
|
||||||
|
string LevelCode,
|
||||||
|
int Order,
|
||||||
|
string Topic,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime? UpdatedAt);
|
DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
|
@ -21,7 +24,9 @@ public record LessonDto(
|
||||||
public record CreateLessonDto(
|
public record CreateLessonDto(
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
int Level);
|
int LevelId,
|
||||||
|
int Order,
|
||||||
|
string Topic);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Data Transfer Object for updating an existing Lesson.
|
/// Data Transfer Object for updating an existing Lesson.
|
||||||
|
|
@ -29,7 +34,9 @@ public record CreateLessonDto(
|
||||||
public record UpdateLessonDto(
|
public record UpdateLessonDto(
|
||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
int Level);
|
int LevelId,
|
||||||
|
int Order,
|
||||||
|
string Topic);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension methods for mapping between Lesson entity and DTOs.
|
/// Extension methods for mapping between Lesson entity and DTOs.
|
||||||
|
|
@ -40,21 +47,23 @@ public static class LessonDtoExtensions
|
||||||
lesson.Id,
|
lesson.Id,
|
||||||
lesson.Title,
|
lesson.Title,
|
||||||
lesson.Description,
|
lesson.Description,
|
||||||
lesson.Level,
|
lesson.LevelId,
|
||||||
GetLevelDescription(lesson.Level),
|
lesson.Level?.Name ?? "Unknown",
|
||||||
|
lesson.Level?.Code ?? "??",
|
||||||
|
lesson.Order,
|
||||||
|
lesson.Topic,
|
||||||
lesson.CreatedAt,
|
lesson.CreatedAt,
|
||||||
lesson.UpdatedAt);
|
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) =>
|
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();
|
var lesson = command.LessonData.ToEntity();
|
||||||
|
|
||||||
// Validate business rules (could be extracted to a validator)
|
// 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");
|
throw new ValidationException("Level must be between 1 and 5");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
namespace GermanApp.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class Lesson
|
public class Lesson
|
||||||
{
|
{
|
||||||
// Private setter for domain behavior, but internal for EF Core
|
|
||||||
public int Id { get; private set; }
|
public int Id { get; private set; }
|
||||||
|
public int LevelId { get; private set; }
|
||||||
public string Title { get; private set; } = string.Empty;
|
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 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 CreatedAt { get; private set; }
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
public DateTime? UpdatedAt { get; private set; }
|
||||||
|
|
||||||
// Navigation properties would go here in EF Core
|
// Navigation property (EF Core will handle this)
|
||||||
// public ICollection<VocabularyWord> Words { get; private set; }
|
public virtual Level? Level { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Constructor for EF Core deserialization.
|
/// Constructor for EF Core deserialization.
|
||||||
|
|
@ -24,35 +27,84 @@ public class Lesson
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Factory method to create a new lesson.
|
/// Factory method to create a new lesson.
|
||||||
/// </summary>
|
/// </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
|
return new Lesson
|
||||||
{
|
{
|
||||||
|
LevelId = levelId,
|
||||||
Title = title,
|
Title = title,
|
||||||
|
Order = order,
|
||||||
|
Topic = topic,
|
||||||
Description = description,
|
Description = description,
|
||||||
Level = level,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the lesson details.
|
/// Updates the lesson's title.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Update(string title, string description, int level)
|
public void UpdateTitle(string newTitle)
|
||||||
{
|
{
|
||||||
Title = title;
|
Title = newTitle;
|
||||||
Description = description;
|
|
||||||
Level = level;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Domain behavior: Check if lesson is at beginner level.
|
/// Updates the lesson's topic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsBeginnerLevel() => Level <= 2;
|
public void UpdateTopic(string newTopic)
|
||||||
|
{
|
||||||
|
Topic = newTopic;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Domain behavior: Check if lesson is at advanced level.
|
/// Updates the lesson's description.
|
||||||
/// </summary>
|
/// </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);
|
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>
|
/// <summary>
|
||||||
/// Repository interface for Lesson entities.
|
/// Repository interface for Lesson entities.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -49,15 +75,61 @@ public interface ILessonRepository : IRepository<Lesson, int>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets lessons by level.
|
/// Gets lessons by level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets beginner-level lessons.
|
/// Gets all lessons ordered by level and lesson order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets advanced-level lessons.
|
/// Gets the first lesson in a level.
|
||||||
/// </summary>
|
/// </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
|
// DbSets for domain entities
|
||||||
|
public DbSet<Level> Levels { get; set; } = null!;
|
||||||
public DbSet<Lesson> Lessons { get; set; } = null!;
|
public DbSet<Lesson> Lessons { get; set; } = null!;
|
||||||
public DbSet<User> Users { get; set; } = null!;
|
public DbSet<User> Users { get; set; } = null!;
|
||||||
|
public DbSet<UserProgress> UserProgress { get; set; } = null!;
|
||||||
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
||||||
|
|
||||||
// Note: Value objects are not stored directly as entities.
|
// Note: Value objects are not stored directly as entities.
|
||||||
|
|
@ -25,16 +27,41 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
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
|
// Configure Lesson entity
|
||||||
modelBuilder.Entity<Lesson>(builder =>
|
modelBuilder.Entity<Lesson>(builder =>
|
||||||
{
|
{
|
||||||
builder.HasKey(l => l.Id);
|
builder.HasKey(l => l.Id);
|
||||||
builder.Property(l => l.Title).IsRequired().HasMaxLength(200);
|
builder.Property(l => l.Title).IsRequired().HasMaxLength(200);
|
||||||
builder.Property(l => l.Description).IsRequired().HasMaxLength(2000);
|
builder.Property(l => l.Description).HasMaxLength(2000);
|
||||||
builder.Property(l => l.Level).IsRequired();
|
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.CreatedAt).IsRequired();
|
||||||
builder.Property(l => l.UpdatedAt).IsRequired(false);
|
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
|
// Value object: GermanWord would be configured as an owned entity
|
||||||
// builder.OwnsMany(l => l.Words, wordBuilder =>
|
// 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
|
// Configure User entity
|
||||||
modelBuilder.Entity<User>(builder =>
|
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)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("character varying(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");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
|
|
@ -46,14 +54,54 @@ namespace GermanApp.Migrations
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LevelId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
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 =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -145,6 +193,54 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("Users");
|
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 =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||||
|
|
@ -153,6 +249,25 @@ namespace GermanApp.Migrations
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.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
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,14 @@ public class LessonRepository : ILessonRepository
|
||||||
public async Task<Lesson?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<Lesson?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Lessons
|
return await _context.Lessons
|
||||||
|
.Include(l => l.Level)
|
||||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Lesson>> GetAllAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Lesson>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Lessons
|
return await _context.Lessons
|
||||||
|
.Include(l => l.Level)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
@ -56,27 +58,104 @@ public class LessonRepository : ILessonRepository
|
||||||
.AnyAsync(l => l.Id == id, cancellationToken);
|
.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
|
return await _context.Lessons
|
||||||
.Where(l => l.Level == level)
|
.Include(l => l.Level)
|
||||||
|
.Where(l => l.LevelId == levelId)
|
||||||
|
.OrderBy(l => l.Order)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Lessons
|
return await _context.Lessons
|
||||||
.Where(l => l.Level <= 2)
|
.Include(l => l.Level)
|
||||||
|
.OrderBy(l => l.Level.Order)
|
||||||
|
.ThenBy(l => l.Order)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.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
|
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()
|
.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.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.
|
/// Seeds the database with initial data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="app">The web application</param>
|
/// <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();
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
@ -24,6 +24,18 @@ public static class SeedDataExtension
|
||||||
// Only seed if there are no users
|
// Only seed if there are no users
|
||||||
if (!dbContext.Users.Any())
|
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
|
// Seed an admin user
|
||||||
var adminUser = User.Create(
|
var adminUser = User.Create(
|
||||||
"admin",
|
"admin",
|
||||||
|
|
@ -44,17 +56,17 @@ public static class SeedDataExtension
|
||||||
// Seed some lessons
|
// Seed some lessons
|
||||||
var lessons = new[]
|
var lessons = new[]
|
||||||
{
|
{
|
||||||
Lesson.Create("Greetings", "Basic German greetings and introductions", 1),
|
Lesson.Create(1, "Greetings", 1, "Greetings", "Basic German greetings and introductions"),
|
||||||
Lesson.Create("Numbers", "German numbers 1-100", 1),
|
Lesson.Create(1, "Numbers", 2, "Numbers", "German numbers 1-100"),
|
||||||
Lesson.Create("Grammar Basics", "Basic German grammar rules", 2),
|
Lesson.Create(2, "Grammar Basics", 1, "Grammar", "Basic German grammar rules"),
|
||||||
Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2),
|
Lesson.Create(2, "Everyday Phrases", 2, "Phrases", "Common phrases for daily conversations"),
|
||||||
Lesson.Create("Advanced Grammar", "Complex German grammar", 4)
|
Lesson.Create(5, "Advanced Grammar", 1, "Grammar", "Complex German grammar")
|
||||||
};
|
};
|
||||||
dbContext.Lessons.AddRange(lessons);
|
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.DTOs;
|
||||||
using GermanApp.Application.UseCases.Commands;
|
using GermanApp.Application.UseCases.Commands;
|
||||||
|
using GermanApp.Domain.Entities;
|
||||||
using GermanApp.Domain.Interfaces;
|
using GermanApp.Domain.Interfaces;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
@ -45,8 +46,13 @@ public static class LessonsEndpoints
|
||||||
// GET /api/lessons/beginner
|
// GET /api/lessons/beginner
|
||||||
group.MapGet("/beginner", async ([FromServices] ILessonRepository repository) =>
|
group.MapGet("/beginner", async ([FromServices] ILessonRepository repository) =>
|
||||||
{
|
{
|
||||||
var lessons = await repository.GetBeginnerLessonsAsync();
|
// Beginner lessons are A1 (level 1) and A2 (level 2)
|
||||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
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")
|
.WithName("GetBeginnerLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
|
|
@ -58,8 +64,13 @@ public static class LessonsEndpoints
|
||||||
// GET /api/lessons/advanced
|
// GET /api/lessons/advanced
|
||||||
group.MapGet("/advanced", async ([FromServices] ILessonRepository repository) =>
|
group.MapGet("/advanced", async ([FromServices] ILessonRepository repository) =>
|
||||||
{
|
{
|
||||||
var lessons = await repository.GetAdvancedLessonsAsync();
|
// Advanced lessons are B2 (level 4) and C1 (level 5)
|
||||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
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")
|
.WithName("GetAdvancedLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
|
|
@ -110,7 +121,7 @@ public static class LessonsEndpoints
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
// Map DTO to entity
|
// Map DTO to entity
|
||||||
existingLesson.Update(dto.Title, dto.Description, dto.Level);
|
existingLesson.UpdateFromDto(dto);
|
||||||
await repository.UpdateAsync(existingLesson);
|
await repository.UpdateAsync(existingLesson);
|
||||||
|
|
||||||
return Results.Ok(existingLesson.ToDto());
|
return Results.Ok(existingLesson.ToDto());
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using GermanApp.Application.DTOs;
|
using GermanApp.Application.DTOs;
|
||||||
|
using GermanApp.Application.Services;
|
||||||
using GermanApp.Application.Interfaces;
|
using GermanApp.Application.Interfaces;
|
||||||
using GermanApp.Application.UseCases.Commands;
|
using GermanApp.Application.UseCases.Commands;
|
||||||
using GermanApp.Domain.Entities;
|
using GermanApp.Domain.Entities;
|
||||||
|
|
@ -115,13 +116,20 @@ try
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register repositories (Infrastructure implementations of Domain interfaces)
|
// Register repositories (Infrastructure implementations of Domain interfaces)
|
||||||
|
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||||
|
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
// APPLICATION LAYER - Use Cases & Services
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Register command handlers
|
// 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>();
|
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
@ -153,12 +161,15 @@ try
|
||||||
name: "api",
|
name: "api",
|
||||||
pattern: "api/{controller}/{action}/{id?}" );
|
pattern: "api/{controller}/{action}/{id?}" );
|
||||||
|
|
||||||
// Seed database with initial data
|
|
||||||
app.SeedDatabase();
|
|
||||||
|
|
||||||
// Map Clean Architecture endpoints
|
// Map Clean Architecture endpoints
|
||||||
app.MapLessonsEndpoints();
|
app.MapLessonsEndpoints();
|
||||||
|
|
||||||
|
// Seed database with initial data
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
await app.SeedDatabaseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Keep original WeatherForecast endpoint for reference
|
// Keep original WeatherForecast endpoint for reference
|
||||||
app.MapGet("/weatherforecast", () =>
|
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
|
### Features
|
||||||
| # | Feature | Description | Hours | Status | Dependencies |
|
| # | 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.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.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 |
|
| 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 | Duration | Hours | Features | Status |
|
||||||
|-------|----------|-------|----------|--------|
|
|-------|----------|-------|----------|--------|
|
||||||
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | 🚀 In Progress (1.1 ✅, 1.2 🚀) |
|
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | ✅ Complete (1.1 ✅, 1.2 ✅) |
|
||||||
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned |
|
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | 🚀 In Progress (2.1 🚀) |
|
||||||
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
|
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
|
||||||
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
|
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
|
||||||
| **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress |
|
| **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Feature: Lesson & Content Management
|
# Feature: Lesson & Content Management
|
||||||
|
|
||||||
> **Status**: ⏳ Planned
|
> **Status**: 🚀 In Progress
|
||||||
> **Priority**: High
|
> **Priority**: High
|
||||||
> **Complexity**: High
|
> **Complexity**: High
|
||||||
> **Estimate**: 10-16 hours
|
> **Estimate**: 10-16 hours
|
||||||
|
|
@ -10,6 +10,12 @@
|
||||||
> **PR**: -
|
> **PR**: -
|
||||||
> **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration
|
> **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
|
## 📌 Overview
|
||||||
|
|
@ -132,20 +138,20 @@ Each lesson contains:
|
||||||
## 🚀 Implementation Plan
|
## 🚀 Implementation Plan
|
||||||
|
|
||||||
### Phase 1: Database & Models (2-3 hours)
|
### Phase 1: Database & Models (2-3 hours)
|
||||||
- [ ] Create Level entity and repository
|
- [x] Create Level entity and repository
|
||||||
- [ ] Create Lesson entity and repository
|
- [x] Create Lesson entity and repository
|
||||||
- [ ] Create UserProgress entity and repository
|
- [x] Create UserProgress entity and repository
|
||||||
- [ ] Set up relationships between entities
|
- [x] Set up relationships between entities
|
||||||
- [ ] Create migrations for Levels, Lessons, UserProgress
|
- [x] Create migrations for Levels, Lessons, UserProgress
|
||||||
- [ ] Seed initial A1 level and lessons
|
- [x] Seed initial A1 level and lessons
|
||||||
|
|
||||||
### Phase 2: Backend Services (3-5 hours)
|
### Phase 2: Backend Services (3-5 hours)
|
||||||
- [ ] Create LevelService with CRUD operations
|
- [x] Create LevelService with CRUD operations
|
||||||
- [ ] Create LessonService with CRUD operations
|
- [x] Create LessonService with CRUD operations
|
||||||
- [ ] Create ProgressService to track user progress
|
- [x] Create ProgressService to track user progress
|
||||||
- [ ] Implement sequential unlocking logic
|
- [x] Implement sequential unlocking logic
|
||||||
- [ ] Create DTOs for Level, Lesson, UserProgress
|
- [x] Create DTOs for Level, Lesson, UserProgress
|
||||||
- [ ] Create mapping profiles (AutoMapper or manual)
|
- [x] Create mapping profiles (manual extension methods)
|
||||||
|
|
||||||
### Phase 3: API Controllers (2-3 hours)
|
### Phase 3: API Controllers (2-3 hours)
|
||||||
- [ ] Create LevelsController
|
- [ ] Create LevelsController
|
||||||
|
|
@ -180,17 +186,21 @@ Each lesson contains:
|
||||||
## ✅ Tasks
|
## ✅ Tasks
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- [ ] Create Domain/Entities/Level.cs
|
- [x] Create Domain/Entities/Level.cs
|
||||||
- [ ] Create Domain/Entities/Lesson.cs
|
- [x] Create Domain/Entities/Lesson.cs
|
||||||
- [ ] Create Domain/Entities/UserProgress.cs
|
- [x] Create Domain/Entities/UserProgress.cs
|
||||||
- [ ] Create Domain/Interfaces/ILevelRepository.cs
|
- [x] Create Domain/Interfaces/ILevelRepository.cs
|
||||||
- [ ] Create Domain/Interfaces/ILessonRepository.cs
|
- [x] Create Domain/Interfaces/ILessonRepository.cs
|
||||||
- [ ] Create Infrastructure/Data/Repositories/LevelRepository.cs
|
- [x] Create Infrastructure/Data/Repositories/LevelRepository.cs
|
||||||
- [ ] Create Infrastructure/Data/Repositories/LessonRepository.cs
|
- [x] Create Infrastructure/Data/Repositories/LessonRepository.cs
|
||||||
- [ ] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
|
- [x] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
|
||||||
- [ ] Create Application/DTOs/LevelDto.cs
|
- [x] Create Application/DTOs/LevelDto.cs
|
||||||
- [ ] Create Application/DTOs/LessonDto.cs
|
- [x] Create Application/DTOs/LessonDto.cs
|
||||||
- [ ] Create Application/DTOs/UserProgressDto.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/LevelService.cs
|
||||||
- [ ] Create Application/Services/LessonService.cs
|
- [ ] Create Application/Services/LessonService.cs
|
||||||
- [ ] Create Application/Services/ProgressService.cs
|
- [ ] Create Application/Services/ProgressService.cs
|
||||||
|
|
@ -201,11 +211,11 @@ Each lesson contains:
|
||||||
- [ ] Write integration tests for controllers
|
- [ ] Write integration tests for controllers
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- [ ] Create migration for Levels table
|
- [x] Create migration for Levels table
|
||||||
- [ ] Create migration for Lessons table
|
- [x] Create migration for Lessons table
|
||||||
- [ ] Create migration for UserProgress table
|
- [x] Create migration for UserProgress table
|
||||||
- [ ] Seed A1 level with initial lessons
|
- [x] Seed A1 level with initial lessons
|
||||||
- [ ] Add indexes for performance
|
- [x] Add indexes for performance
|
||||||
|
|
||||||
### Business Logic
|
### Business Logic
|
||||||
- [ ] Implement LessonUnlockService
|
- [ ] Implement LessonUnlockService
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue