Compare commits

..

No commits in common. "c65214737e9bd55cc6139c439504407f7da18e2b" and "a492cdd7906c075d50635bc7d96404570a120038" have entirely different histories.

26 changed files with 96 additions and 2384 deletions

View file

@ -10,11 +10,8 @@ public record LessonDto(
int Id,
string Title,
string Description,
int LevelId,
string LevelName,
string LevelCode,
int Order,
string Topic,
int Level,
string LevelDescription,
DateTime CreatedAt,
DateTime? UpdatedAt);
@ -24,9 +21,7 @@ public record LessonDto(
public record CreateLessonDto(
string Title,
string Description,
int LevelId,
int Order,
string Topic);
int Level);
/// <summary>
/// Data Transfer Object for updating an existing Lesson.
@ -34,9 +29,7 @@ public record CreateLessonDto(
public record UpdateLessonDto(
string Title,
string Description,
int LevelId,
int Order,
string Topic);
int Level);
/// <summary>
/// Extension methods for mapping between Lesson entity and DTOs.
@ -47,23 +40,21 @@ public static class LessonDtoExtensions
lesson.Id,
lesson.Title,
lesson.Description,
lesson.LevelId,
lesson.Level?.Name ?? "Unknown",
lesson.Level?.Code ?? "??",
lesson.Order,
lesson.Topic,
lesson.Level,
GetLevelDescription(lesson.Level),
lesson.CreatedAt,
lesson.UpdatedAt);
public static Lesson ToEntity(this CreateLessonDto dto) =>
Lesson.Create(dto.LevelId, dto.Title, dto.Order, dto.Topic, dto.Description);
public static void UpdateFromDto(this Lesson lesson, UpdateLessonDto dto)
private static string GetLevelDescription(int level) => level switch
{
lesson.UpdateTitle(dto.Title);
lesson.UpdateDescription(dto.Description);
lesson.UpdateLevel(dto.LevelId);
lesson.UpdateOrder(dto.Order);
lesson.UpdateTopic(dto.Topic);
}
1 => "A1 (Beginner)",
2 => "A2 (Elementary)",
3 => "B1 (Intermediate)",
4 => "B2 (Upper Intermediate)",
5 => "C1 (Advanced)",
_ => "Unknown"
};
public static Lesson ToEntity(this CreateLessonDto dto) =>
Lesson.Create(dto.Title, dto.Description, dto.Level);
}

View file

@ -1,51 +0,0 @@
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);
}
}

View file

@ -1,80 +0,0 @@
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);
}
}
}

View file

@ -1,159 +0,0 @@
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();
}
}

View file

@ -1,101 +0,0 @@
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();
}
}

View file

@ -1,206 +0,0 @@
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;
}
}

View file

@ -29,7 +29,7 @@ public class CreateLessonCommandHandler : ICommandHandler<CreateLessonCommand, L
var lesson = command.LessonData.ToEntity();
// Validate business rules (could be extracted to a validator)
if (lesson.LevelId < 1 || lesson.LevelId > 5)
if (lesson.Level < 1 || lesson.Level > 5)
{
throw new ValidationException("Level must be between 1 and 5");
}

View file

@ -1,23 +1,20 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a lesson in the DeutschLernen system.
/// Lessons are organized within CEFR levels and must be completed in order.
/// Represents a German language lesson in the system.
/// </summary>
public class Lesson
{
// Private setter for domain behavior, but internal for EF Core
public int Id { get; private set; }
public int LevelId { get; private set; }
public string Title { get; private set; } = string.Empty;
public int Order { get; private set; }
public string Topic { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public bool IsActive { get; private set; } = true;
public int Level { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// Navigation property (EF Core will handle this)
public virtual Level? Level { get; private set; }
// Navigation properties would go here in EF Core
// public ICollection<VocabularyWord> Words { get; private set; }
/// <summary>
/// Constructor for EF Core deserialization.
@ -27,84 +24,35 @@ public class Lesson
/// <summary>
/// Factory method to create a new lesson.
/// </summary>
/// <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 = "")
public static Lesson Create(string title, string description, int level)
{
return new Lesson
{
LevelId = levelId,
Title = title,
Order = order,
Topic = topic,
Description = description,
Level = level,
CreatedAt = DateTime.UtcNow
};
}
/// <summary>
/// Updates the lesson's title.
/// Updates the lesson details.
/// </summary>
public void UpdateTitle(string newTitle)
public void Update(string title, string description, int level)
{
Title = newTitle;
Title = title;
Description = description;
Level = level;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the lesson's topic.
/// Domain behavior: Check if lesson is at beginner level.
/// </summary>
public void UpdateTopic(string newTopic)
{
Topic = newTopic;
UpdatedAt = DateTime.UtcNow;
}
public bool IsBeginnerLevel() => Level <= 2;
/// <summary>
/// Updates the lesson's description.
/// Domain behavior: Check if lesson is at advanced level.
/// </summary>
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;
}
public bool IsAdvancedLevel() => Level >= 4;
}

View file

@ -1,57 +0,0 @@
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;
}
}

View file

@ -1,82 +0,0 @@
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;
}

View file

@ -41,32 +41,6 @@ public interface IRepository<TEntity, TId> where TEntity : class
Task<bool> ExistsAsync(TId id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for Level entities.
/// </summary>
public interface ILevelRepository : IRepository<Level, int>
{
/// <summary>
/// Gets a level by its code (e.g., "A1", "B2").
/// </summary>
Task<Level?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all levels ordered by their sort order.
/// </summary>
Task<IReadOnlyList<Level>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the first level (lowest order number).
/// </summary>
Task<Level?> GetFirstLevelAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the next level after the specified one.
/// </summary>
Task<Level?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for Lesson entities.
/// </summary>
@ -75,61 +49,15 @@ public interface ILessonRepository : IRepository<Lesson, int>
/// <summary>
/// Gets lessons by level.
/// </summary>
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all lessons ordered by level and lesson order.
/// Gets beginner-level lessons.
/// </summary>
Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the first lesson in a level.
/// Gets advanced-level lessons.
/// </summary>
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);
Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default);
}

View file

@ -14,10 +14,8 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
}
// DbSets for domain entities
public DbSet<Level> Levels { get; set; } = null!;
public DbSet<Lesson> Lessons { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserProgress> UserProgress { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
// Note: Value objects are not stored directly as entities.
@ -27,41 +25,16 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
{
base.OnModelCreating(modelBuilder);
// Configure Level entity
modelBuilder.Entity<Level>(builder =>
{
builder.HasKey(l => l.Id);
builder.Property(l => l.Name).IsRequired().HasMaxLength(100);
builder.Property(l => l.Code).IsRequired().HasMaxLength(10);
builder.Property(l => l.Order).IsRequired();
// Ensure unique constraints
builder.HasIndex(l => l.Code).IsUnique();
builder.HasIndex(l => l.Order).IsUnique();
});
// Configure Lesson entity
modelBuilder.Entity<Lesson>(builder =>
{
builder.HasKey(l => l.Id);
builder.Property(l => l.Title).IsRequired().HasMaxLength(200);
builder.Property(l => l.Description).HasMaxLength(2000);
builder.Property(l => l.Topic).IsRequired().HasMaxLength(100);
builder.Property(l => l.LevelId).IsRequired();
builder.Property(l => l.Order).IsRequired();
builder.Property(l => l.IsActive).HasDefaultValue(true);
builder.Property(l => l.Description).IsRequired().HasMaxLength(2000);
builder.Property(l => l.Level).IsRequired();
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.UpdatedAt).IsRequired(false);
// Foreign key to Level with unique constraint per level
builder.HasOne(l => l.Level)
.WithMany()
.HasForeignKey(l => l.LevelId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one lesson per level per order
builder.HasIndex(l => new { l.LevelId, l.Order }).IsUnique();
// Value object: GermanWord would be configured as an owned entity
// builder.OwnsMany(l => l.Words, wordBuilder =>
// {
@ -72,31 +45,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
// });
});
// Configure UserProgress entity
modelBuilder.Entity<UserProgress>(builder =>
{
builder.HasKey(up => up.Id);
builder.Property(up => up.UserId).IsRequired();
builder.Property(up => up.LessonId).IsRequired();
builder.Property(up => up.IsCompleted).HasDefaultValue(false);
builder.Property(up => up.QuizScore).HasDefaultValue(0);
builder.Property(up => up.LastAttemptDate).IsRequired();
// Foreign keys
builder.HasOne(up => up.User)
.WithMany()
.HasForeignKey(up => up.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(up => up.Lesson)
.WithMany()
.HasForeignKey(up => up.LessonId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one progress record per user per lesson
builder.HasIndex(up => new { up.UserId, up.LessonId }).IsUnique();
});
// Configure User entity
modelBuilder.Entity<User>(builder =>
{

View file

@ -1,277 +0,0 @@
// <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
}
}
}

View file

@ -1,159 +0,0 @@
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");
}
}
}

View file

@ -38,15 +38,7 @@ namespace GermanApp.Migrations
.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")
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<string>("Title")
@ -54,54 +46,14 @@ namespace GermanApp.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LevelId", "Order")
.IsUnique();
b.ToTable("Lessons");
});
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Order")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Order")
.IsUnique();
b.ToTable("Levels");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
@ -193,54 +145,6 @@ namespace GermanApp.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTime>("LastAttemptDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("LessonId")
.HasColumnType("integer");
b.Property<int>("QuizScore")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("LessonId");
b.HasIndex("UserId", "LessonId")
.IsUnique();
b.ToTable("UserProgress");
});
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
.WithMany()
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{
b.HasOne("GermanApp.Domain.Entities.User", null)
@ -249,25 +153,6 @@ namespace GermanApp.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
{
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
.WithMany()
.HasForeignKey("LessonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GermanApp.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lesson");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}

View file

@ -21,14 +21,12 @@ public class LessonRepository : ILessonRepository
public async Task<Lesson?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Include(l => l.Level)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Include(l => l.Level)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
@ -58,104 +56,27 @@ public class LessonRepository : ILessonRepository
.AnyAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Include(l => l.Level)
.Where(l => l.LevelId == levelId)
.OrderBy(l => l.Order)
.Where(l => l.Level == level)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Include(l => l.Level)
.OrderBy(l => l.Level.Order)
.ThenBy(l => l.Order)
.Where(l => l.Level <= 2)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<Lesson?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
{
return await _context.Lessons
.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)
.Where(l => l.Level >= 4)
.AsNoTracking()
.ToListAsync(cancellationToken);
if (allLessons.Count == 0)
return new List<Lesson>();
// Get completed lessons for this user
var completedLessonIds = await _context.UserProgress
.Where(up => up.UserId == userId && up.IsCompleted)
.Select(up => up.LessonId)
.ToListAsync(cancellationToken);
// Find the first lesson in the first level
var firstLesson = allLessons.First();
// If user hasn't completed any lessons, they can only access the first one
if (completedLessonIds.Count == 0)
return new List<Lesson> { firstLesson };
// Get the highest ordered lesson that the user has completed
var completedLessons = allLessons
.Where(l => completedLessonIds.Contains(l.Id))
.OrderBy(l => l.Level.Order)
.ThenBy(l => l.Order)
.ToList();
if (completedLessons.Count == 0)
return new List<Lesson> { firstLesson };
var lastCompleted = completedLessons.Last();
// User can access all lessons up to and including the next one after the last completed
var accessibleLessons = allLessons
.TakeWhile(l => l.Id != lastCompleted.Id)
.ToList();
// Add the last completed and the next one
accessibleLessons.Add(lastCompleted);
var nextLessonIndex = allLessons.FindIndex(l => l.Id == lastCompleted.Id) + 1;
if (nextLessonIndex < allLessons.Count)
{
accessibleLessons.Add(allLessons[nextLessonIndex]);
}
return accessibleLessons;
}
}

View file

@ -1,95 +0,0 @@
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);
}
}

View file

@ -1,127 +0,0 @@
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;
}
}

View file

@ -13,7 +13,7 @@ public static class SeedDataExtension
/// Seeds the database with initial data.
/// </summary>
/// <param name="app">The web application</param>
public static async Task SeedDatabaseAsync(this WebApplication app)
public static void SeedDatabase(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@ -24,18 +24,6 @@ public static class SeedDataExtension
// Only seed if there are no users
if (!dbContext.Users.Any())
{
// Seed CEFR levels first
var levels = new[]
{
Level.Create("Beginner A1", "A1", 1),
Level.Create("Elementary A2", "A2", 2),
Level.Create("Intermediate B1", "B1", 3),
Level.Create("Upper Intermediate B2", "B2", 4),
Level.Create("Advanced C1", "C1", 5)
};
dbContext.Levels.AddRange(levels);
await dbContext.SaveChangesAsync();
// Seed an admin user
var adminUser = User.Create(
"admin",
@ -56,17 +44,17 @@ public static class SeedDataExtension
// Seed some lessons
var lessons = new[]
{
Lesson.Create(1, "Greetings", 1, "Greetings", "Basic German greetings and introductions"),
Lesson.Create(1, "Numbers", 2, "Numbers", "German numbers 1-100"),
Lesson.Create(2, "Grammar Basics", 1, "Grammar", "Basic German grammar rules"),
Lesson.Create(2, "Everyday Phrases", 2, "Phrases", "Common phrases for daily conversations"),
Lesson.Create(5, "Advanced Grammar", 1, "Grammar", "Complex German grammar")
Lesson.Create("Greetings", "Basic German greetings and introductions", 1),
Lesson.Create("Numbers", "German numbers 1-100", 1),
Lesson.Create("Grammar Basics", "Basic German grammar rules", 2),
Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2),
Lesson.Create("Advanced Grammar", "Complex German grammar", 4)
};
dbContext.Lessons.AddRange(lessons);
await dbContext.SaveChangesAsync();
dbContext.SaveChanges();
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons.");
Console.WriteLine("Database seeded with admin user, test user, and sample lessons.");
}
}
}

View file

@ -1,6 +1,5 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -46,13 +45,8 @@ public static class LessonsEndpoints
// GET /api/lessons/beginner
group.MapGet("/beginner", async ([FromServices] ILessonRepository repository) =>
{
// Beginner lessons are A1 (level 1) and A2 (level 2)
var beginnerLessons = new List<Lesson>();
var level1Lessons = await repository.GetByLevelAsync(1);
var level2Lessons = await repository.GetByLevelAsync(2);
beginnerLessons.AddRange(level1Lessons);
beginnerLessons.AddRange(level2Lessons);
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
var lessons = await repository.GetBeginnerLessonsAsync();
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.WithName("GetBeginnerLessons")
.WithOpenApi(operation => new(operation)
@ -64,13 +58,8 @@ public static class LessonsEndpoints
// GET /api/lessons/advanced
group.MapGet("/advanced", async ([FromServices] ILessonRepository repository) =>
{
// Advanced lessons are B2 (level 4) and C1 (level 5)
var advancedLessons = new List<Lesson>();
var level4Lessons = await repository.GetByLevelAsync(4);
var level5Lessons = await repository.GetByLevelAsync(5);
advancedLessons.AddRange(level4Lessons);
advancedLessons.AddRange(level5Lessons);
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
var lessons = await repository.GetAdvancedLessonsAsync();
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.WithName("GetAdvancedLessons")
.WithOpenApi(operation => new(operation)
@ -121,7 +110,7 @@ public static class LessonsEndpoints
return Results.NotFound();
// Map DTO to entity
existingLesson.UpdateFromDto(dto);
existingLesson.Update(dto.Title, dto.Description, dto.Level);
await repository.UpdateAsync(existingLesson);
return Results.Ok(existingLesson.ToDto());

View file

@ -1,5 +1,4 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Application.Interfaces;
using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Entities;
@ -116,20 +115,13 @@ try
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register command handlers
// Register application services
builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>();
builder.Services.AddScoped<ProgressService>();
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
var app = builder.Build();
@ -161,15 +153,12 @@ try
name: "api",
pattern: "api/{controller}/{action}/{id?}" );
// Seed database with initial data
app.SeedDatabase();
// Map Clean Architecture endpoints
app.MapLessonsEndpoints();
// Seed database with initial data
if (app.Environment.IsDevelopment())
{
await app.SeedDatabaseAsync();
}
// Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () =>
{

View file

@ -1,161 +0,0 @@
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);
}
}

View file

@ -1,143 +0,0 @@
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);
}
}

View file

@ -1,167 +0,0 @@
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);
}
}

View file

@ -91,7 +91,7 @@ Implement the core backend functionality, including lesson management, AI servic
### Features
| # | Feature | Description | Hours | Status | Dependencies |
|---|---------|-------------|-------|--------|--------------|
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | 🚀 In Progress (Phase 1 & 2 ✅) | Phase 1 |
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 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.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 |
@ -265,8 +265,8 @@ Implement the complete React + TypeScript frontend application with all UI compo
| Phase | Duration | Hours | Features | Status |
|-------|----------|-------|----------|--------|
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | ✅ Complete (1.1 ✅, 1.2 ✅) |
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | 🚀 In Progress (2.1 🚀) |
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | 🚀 In Progress (1.1 ✅, 1.2 🚀) |
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned |
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
| **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress |

View file

@ -1,6 +1,6 @@
# Feature: Lesson & Content Management
> **Status**: 🚀 In Progress
> **Status**: ⏳ Planned
> **Priority**: High
> **Complexity**: High
> **Estimate**: 10-16 hours
@ -10,12 +10,6 @@
> **PR**: -
> **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration
**Phase 1: Database & Models - COMPLETED ✅**
**Phase 2: Backend Services - COMPLETED ✅**
**All Phase 1 & Phase 2 tasks are complete. Ready for Phase 3: API Controllers.**
---
## 📌 Overview
@ -138,20 +132,20 @@ Each lesson contains:
## 🚀 Implementation Plan
### Phase 1: Database & Models (2-3 hours)
- [x] Create Level entity and repository
- [x] Create Lesson entity and repository
- [x] Create UserProgress entity and repository
- [x] Set up relationships between entities
- [x] Create migrations for Levels, Lessons, UserProgress
- [x] Seed initial A1 level and lessons
- [ ] Create Level entity and repository
- [ ] Create Lesson entity and repository
- [ ] Create UserProgress entity and repository
- [ ] Set up relationships between entities
- [ ] Create migrations for Levels, Lessons, UserProgress
- [ ] Seed initial A1 level and lessons
### Phase 2: Backend Services (3-5 hours)
- [x] Create LevelService with CRUD operations
- [x] Create LessonService with CRUD operations
- [x] Create ProgressService to track user progress
- [x] Implement sequential unlocking logic
- [x] Create DTOs for Level, Lesson, UserProgress
- [x] Create mapping profiles (manual extension methods)
- [ ] Create LevelService with CRUD operations
- [ ] Create LessonService with CRUD operations
- [ ] Create ProgressService to track user progress
- [ ] Implement sequential unlocking logic
- [ ] Create DTOs for Level, Lesson, UserProgress
- [ ] Create mapping profiles (AutoMapper or manual)
### Phase 3: API Controllers (2-3 hours)
- [ ] Create LevelsController
@ -186,21 +180,17 @@ Each lesson contains:
## ✅ Tasks
### Backend
- [x] Create Domain/Entities/Level.cs
- [x] Create Domain/Entities/Lesson.cs
- [x] Create Domain/Entities/UserProgress.cs
- [x] Create Domain/Interfaces/ILevelRepository.cs
- [x] Create Domain/Interfaces/ILessonRepository.cs
- [x] Create Infrastructure/Data/Repositories/LevelRepository.cs
- [x] Create Infrastructure/Data/Repositories/LessonRepository.cs
- [x] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
- [x] Create Application/DTOs/LevelDto.cs
- [x] Create Application/DTOs/LessonDto.cs
- [x] Create Application/DTOs/UserProgressDto.cs
- [x] Create Application/Services/LevelService.cs
- [x] Create Application/Services/LessonService.cs
- [x] Create Application/Services/ProgressService.cs
- [x] Write unit tests for services (30 tests)
- [ ] Create Domain/Entities/Level.cs
- [ ] Create Domain/Entities/Lesson.cs
- [ ] Create Domain/Entities/UserProgress.cs
- [ ] Create Domain/Interfaces/ILevelRepository.cs
- [ ] Create Domain/Interfaces/ILessonRepository.cs
- [ ] Create Infrastructure/Data/Repositories/LevelRepository.cs
- [ ] Create Infrastructure/Data/Repositories/LessonRepository.cs
- [ ] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
- [ ] Create Application/DTOs/LevelDto.cs
- [ ] Create Application/DTOs/LessonDto.cs
- [ ] Create Application/DTOs/UserProgressDto.cs
- [ ] Create Application/Services/LevelService.cs
- [ ] Create Application/Services/LessonService.cs
- [ ] Create Application/Services/ProgressService.cs
@ -211,11 +201,11 @@ Each lesson contains:
- [ ] Write integration tests for controllers
### Database
- [x] Create migration for Levels table
- [x] Create migration for Lessons table
- [x] Create migration for UserProgress table
- [x] Seed A1 level with initial lessons
- [x] Add indexes for performance
- [ ] Create migration for Levels table
- [ ] Create migration for Lessons table
- [ ] Create migration for UserProgress table
- [ ] Seed A1 level with initial lessons
- [ ] Add indexes for performance
### Business Logic
- [ ] Implement LessonUnlockService