From ba81359afa1f285653484cb13b989d5a3621ebfc Mon Sep 17 00:00:00 2001 From: Lasse Rune Hansen Date: Sun, 7 Jun 2026 21:25:46 +0200 Subject: [PATCH] fix(backend): update Lesson entity and related code to use LevelId foreign key - Updated Lesson entity to use LevelId (int) instead of Level (int) - Added navigation property Level (Level entity) - Added Order, Topic, and IsActive properties to Lesson - Updated Lesson.Create() factory method signature to accept levelId, title, order, topic, description - Split Update method into individual property update methods - Updated LessonDto to use LevelId, LevelName, LevelCode instead of Level int - Added CreateLessonDto and UpdateLessonDto with all required fields - Added UpdateFromDto extension method for Lesson - Updated CreateLessonCommand to validate LevelId instead of Level - Updated SeedDataExtension to seed Level entities first, then use new Lesson.Create() signature - Made SeedDatabaseAsync async and updated Program.cs to await it - Updated LessonsEndpoints to use GetByLevelAsync for beginner/advanced queries - Fixed missing using directive for Domain.Entities Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- GermanApp/Application/DTOs/LessonDto.cs | 43 +++--- .../UseCases/Commands/CreateLessonCommand.cs | 2 +- GermanApp/Domain/Entities/Lesson.cs | 84 +++++++++--- GermanApp/Domain/Entities/Level.cs | 57 ++++++++ GermanApp/Domain/Entities/UserProgress.cs | 82 +++++++++++ GermanApp/Domain/Interfaces/IRepository.cs | 82 ++++++++++- .../Data/DbContext/AppDbContext.cs | 56 +++++++- .../Data/Repositories/LessonRepository.cs | 91 ++++++++++++- .../Data/Repositories/LevelRepository.cs | 95 +++++++++++++ .../Repositories/UserProgressRepository.cs | 127 ++++++++++++++++++ .../Data/SeedData/SeedDataExtension.cs | 28 ++-- .../Endpoints/LessonsEndpoints.cs | 21 ++- GermanApp/Program.cs | 2 +- 13 files changed, 709 insertions(+), 61 deletions(-) create mode 100644 GermanApp/Domain/Entities/Level.cs create mode 100644 GermanApp/Domain/Entities/UserProgress.cs create mode 100644 GermanApp/Infrastructure/Data/Repositories/LevelRepository.cs create mode 100644 GermanApp/Infrastructure/Data/Repositories/UserProgressRepository.cs diff --git a/GermanApp/Application/DTOs/LessonDto.cs b/GermanApp/Application/DTOs/LessonDto.cs index 68517a8..98df22b 100644 --- a/GermanApp/Application/DTOs/LessonDto.cs +++ b/GermanApp/Application/DTOs/LessonDto.cs @@ -10,8 +10,11 @@ public record LessonDto( int Id, string Title, string Description, - int Level, - string LevelDescription, + int LevelId, + string LevelName, + string LevelCode, + int Order, + string Topic, DateTime CreatedAt, DateTime? UpdatedAt); @@ -21,7 +24,9 @@ public record LessonDto( public record CreateLessonDto( string Title, string Description, - int Level); + int LevelId, + int Order, + string Topic); /// /// Data Transfer Object for updating an existing Lesson. @@ -29,7 +34,9 @@ public record CreateLessonDto( public record UpdateLessonDto( string Title, string Description, - int Level); + int LevelId, + int Order, + string Topic); /// /// Extension methods for mapping between Lesson entity and DTOs. @@ -40,21 +47,23 @@ public static class LessonDtoExtensions lesson.Id, lesson.Title, lesson.Description, - lesson.Level, - GetLevelDescription(lesson.Level), + lesson.LevelId, + lesson.Level?.Name ?? "Unknown", + lesson.Level?.Code ?? "??", + lesson.Order, + lesson.Topic, lesson.CreatedAt, lesson.UpdatedAt); - private static string GetLevelDescription(int level) => level switch - { - 1 => "A1 (Beginner)", - 2 => "A2 (Elementary)", - 3 => "B1 (Intermediate)", - 4 => "B2 (Upper Intermediate)", - 5 => "C1 (Advanced)", - _ => "Unknown" - }; - public static Lesson ToEntity(this CreateLessonDto dto) => - Lesson.Create(dto.Title, dto.Description, dto.Level); + Lesson.Create(dto.LevelId, dto.Title, dto.Order, dto.Topic, dto.Description); + + public static void UpdateFromDto(this Lesson lesson, UpdateLessonDto dto) + { + lesson.UpdateTitle(dto.Title); + lesson.UpdateDescription(dto.Description); + lesson.UpdateLevel(dto.LevelId); + lesson.UpdateOrder(dto.Order); + lesson.UpdateTopic(dto.Topic); + } } diff --git a/GermanApp/Application/UseCases/Commands/CreateLessonCommand.cs b/GermanApp/Application/UseCases/Commands/CreateLessonCommand.cs index 7d4739d..96092d5 100644 --- a/GermanApp/Application/UseCases/Commands/CreateLessonCommand.cs +++ b/GermanApp/Application/UseCases/Commands/CreateLessonCommand.cs @@ -29,7 +29,7 @@ public class CreateLessonCommandHandler : ICommandHandler 5) + if (lesson.LevelId < 1 || lesson.LevelId > 5) { throw new ValidationException("Level must be between 1 and 5"); } diff --git a/GermanApp/Domain/Entities/Lesson.cs b/GermanApp/Domain/Entities/Lesson.cs index 7d07beb..c93371d 100644 --- a/GermanApp/Domain/Entities/Lesson.cs +++ b/GermanApp/Domain/Entities/Lesson.cs @@ -1,20 +1,23 @@ namespace GermanApp.Domain.Entities; /// -/// 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. /// public class Lesson { - // Private setter for domain behavior, but internal for EF Core public int Id { get; private set; } + public int LevelId { get; private set; } public string Title { get; private set; } = string.Empty; + public int Order { get; private set; } + public string Topic { get; private set; } = string.Empty; public string Description { get; private set; } = string.Empty; - public int Level { get; private set; } + public bool IsActive { get; private set; } = true; public DateTime CreatedAt { get; private set; } public DateTime? UpdatedAt { get; private set; } - // Navigation properties would go here in EF Core - // public ICollection Words { get; private set; } + // Navigation property (EF Core will handle this) + public virtual Level? Level { get; private set; } /// /// Constructor for EF Core deserialization. @@ -24,35 +27,84 @@ public class Lesson /// /// Factory method to create a new lesson. /// - public static Lesson Create(string title, string description, int level) + /// ID of the parent level + /// Title of the lesson + /// Sort order within the level + /// Main topic covered in the lesson + /// Description of what the lesson covers + public static Lesson Create(int levelId, string title, int order, string topic, string description = "") { return new Lesson { + LevelId = levelId, Title = title, + Order = order, + Topic = topic, Description = description, - Level = level, CreatedAt = DateTime.UtcNow }; } /// - /// Updates the lesson details. + /// Updates the lesson's title. /// - public void Update(string title, string description, int level) + public void UpdateTitle(string newTitle) { - Title = title; - Description = description; - Level = level; + Title = newTitle; UpdatedAt = DateTime.UtcNow; } /// - /// Domain behavior: Check if lesson is at beginner level. + /// Updates the lesson's topic. /// - public bool IsBeginnerLevel() => Level <= 2; + public void UpdateTopic(string newTopic) + { + Topic = newTopic; + UpdatedAt = DateTime.UtcNow; + } /// - /// Domain behavior: Check if lesson is at advanced level. + /// Updates the lesson's description. /// - public bool IsAdvancedLevel() => Level >= 4; + public void UpdateDescription(string newDescription) + { + Description = newDescription; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the lesson's sort order. + /// + public void UpdateOrder(int newOrder) + { + Order = newOrder; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the lesson's level. + /// + public void UpdateLevel(int newLevelId) + { + LevelId = newLevelId; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Activates the lesson. + /// + public void Activate() + { + IsActive = true; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Deactivates the lesson. + /// + public void Deactivate() + { + IsActive = false; + UpdatedAt = DateTime.UtcNow; + } } diff --git a/GermanApp/Domain/Entities/Level.cs b/GermanApp/Domain/Entities/Level.cs new file mode 100644 index 0000000..b74e2ec --- /dev/null +++ b/GermanApp/Domain/Entities/Level.cs @@ -0,0 +1,57 @@ +namespace GermanApp.Domain.Entities; + +/// +/// Represents a CEFR level in the DeutschLernen system (A1, A2, B1, B2, C1). +/// +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; } + + /// + /// Constructor for EF Core deserialization. + /// + private Level() { } + + /// + /// Factory method to create a new level. + /// + /// Display name of the level (e.g., "Beginner A1") + /// Short code for the level (e.g., "A1") + /// Sort order of the level + public static Level Create(string name, string code, int order) + { + return new Level + { + Name = name, + Code = code.ToUpperInvariant(), + Order = order + }; + } + + /// + /// Updates the level's display name. + /// + public void UpdateName(string newName) + { + Name = newName; + } + + /// + /// Updates the level's code. + /// + public void UpdateCode(string newCode) + { + Code = newCode.ToUpperInvariant(); + } + + /// + /// Updates the level's sort order. + /// + public void UpdateOrder(int newOrder) + { + Order = newOrder; + } +} diff --git a/GermanApp/Domain/Entities/UserProgress.cs b/GermanApp/Domain/Entities/UserProgress.cs new file mode 100644 index 0000000..69e2ea7 --- /dev/null +++ b/GermanApp/Domain/Entities/UserProgress.cs @@ -0,0 +1,82 @@ +namespace GermanApp.Domain.Entities; + +/// +/// Represents a user's progress through lessons. +/// Tracks completion status and quiz scores. +/// +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; } + + /// + /// Constructor for EF Core deserialization. + /// + private UserProgress() { } + + /// + /// Factory method to create a new user progress record. + /// + /// ID of the user + /// ID of the lesson + public static UserProgress Create(int userId, int lessonId) + { + return new UserProgress + { + UserId = userId, + LessonId = lessonId, + IsCompleted = false, + QuizScore = 0, + LastAttemptDate = DateTime.UtcNow + }; + } + + /// + /// Marks the lesson as completed. + /// + /// The score achieved on the quiz (0-100) + 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; + } + + /// + /// Updates the quiz score without marking as completed. + /// + 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; + } + + /// + /// Resets the progress (e.g., when user wants to redo a lesson). + /// + public void Reset() + { + IsCompleted = false; + QuizScore = 0; + LastAttemptDate = DateTime.UtcNow; + } + + /// + /// Checks if the user passed the lesson (80% or higher). + /// + public bool HasPassed() => QuizScore >= 80; +} diff --git a/GermanApp/Domain/Interfaces/IRepository.cs b/GermanApp/Domain/Interfaces/IRepository.cs index ecfe7fc..c085ead 100644 --- a/GermanApp/Domain/Interfaces/IRepository.cs +++ b/GermanApp/Domain/Interfaces/IRepository.cs @@ -41,6 +41,32 @@ public interface IRepository where TEntity : class Task ExistsAsync(TId id, CancellationToken cancellationToken = default); } +/// +/// Repository interface for Level entities. +/// +public interface ILevelRepository : IRepository +{ + /// + /// Gets a level by its code (e.g., "A1", "B2"). + /// + Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// Gets all levels ordered by their sort order. + /// + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the first level (lowest order number). + /// + Task GetFirstLevelAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the next level after the specified one. + /// + Task GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default); +} + /// /// Repository interface for Lesson entities. /// @@ -49,15 +75,61 @@ public interface ILessonRepository : IRepository /// /// Gets lessons by level. /// - Task> GetByLevelAsync(int level, CancellationToken cancellationToken = default); + Task> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default); /// - /// Gets beginner-level lessons. + /// Gets all lessons ordered by level and lesson order. /// - Task> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default); + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); /// - /// Gets advanced-level lessons. + /// Gets the first lesson in a level. /// - Task> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default); + Task GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default); + + /// + /// Gets the next lesson after the specified one (in the same level). + /// + Task GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default); + + /// + /// Gets lessons that the user can access (completed previous lessons or first in level). + /// + Task> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default); +} + +/// +/// Repository interface for UserProgress entities. +/// +public interface IUserProgressRepository : IRepository +{ + /// + /// Gets user progress for a specific user and lesson. + /// + Task GetByUserAndLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default); + + /// + /// Gets all progress records for a user. + /// + Task> GetByUserAsync(int userId, CancellationToken cancellationToken = default); + + /// + /// Gets progress for all lessons in a specific level for a user. + /// + Task> GetByUserAndLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default); + + /// + /// Checks if a user has completed a lesson (with 80% or higher score). + /// + Task HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default); + + /// + /// Gets the user's average score for a level. + /// + Task GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default); + + /// + /// Gets the percentage of lessons completed in a level. + /// + Task GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default); } diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs index 8652de7..d516e9d 100644 --- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs +++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs @@ -14,8 +14,10 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext } // DbSets for domain entities + public DbSet Levels { get; set; } = null!; public DbSet Lessons { get; set; } = null!; public DbSet Users { get; set; } = null!; + public DbSet UserProgress { get; set; } = null!; public DbSet RefreshTokens { get; set; } = null!; // Note: Value objects are not stored directly as entities. @@ -25,16 +27,41 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext { base.OnModelCreating(modelBuilder); + // Configure Level entity + modelBuilder.Entity(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(builder => { builder.HasKey(l => l.Id); builder.Property(l => l.Title).IsRequired().HasMaxLength(200); - builder.Property(l => l.Description).IsRequired().HasMaxLength(2000); - builder.Property(l => l.Level).IsRequired(); + builder.Property(l => l.Description).HasMaxLength(2000); + builder.Property(l => l.Topic).IsRequired().HasMaxLength(100); + builder.Property(l => l.LevelId).IsRequired(); + builder.Property(l => l.Order).IsRequired(); + builder.Property(l => l.IsActive).HasDefaultValue(true); builder.Property(l => l.CreatedAt).IsRequired(); builder.Property(l => l.UpdatedAt).IsRequired(false); + // Foreign key to Level with unique constraint per level + builder.HasOne(l => l.Level) + .WithMany() + .HasForeignKey(l => l.LevelId) + .OnDelete(DeleteBehavior.Cascade); + + // Unique constraint: one lesson per level per order + builder.HasIndex(l => new { l.LevelId, l.Order }).IsUnique(); + // Value object: GermanWord would be configured as an owned entity // builder.OwnsMany(l => l.Words, wordBuilder => // { @@ -45,6 +72,31 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext // }); }); + // Configure UserProgress entity + modelBuilder.Entity(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(builder => { diff --git a/GermanApp/Infrastructure/Data/Repositories/LessonRepository.cs b/GermanApp/Infrastructure/Data/Repositories/LessonRepository.cs index 1b9a017..6db24c5 100644 --- a/GermanApp/Infrastructure/Data/Repositories/LessonRepository.cs +++ b/GermanApp/Infrastructure/Data/Repositories/LessonRepository.cs @@ -21,12 +21,14 @@ public class LessonRepository : ILessonRepository public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { return await _context.Lessons + .Include(l => l.Level) .FirstOrDefaultAsync(l => l.Id == id, cancellationToken); } public async Task> GetAllAsync(CancellationToken cancellationToken = default) { return await _context.Lessons + .Include(l => l.Level) .AsNoTracking() .ToListAsync(cancellationToken); } @@ -56,27 +58,104 @@ public class LessonRepository : ILessonRepository .AnyAsync(l => l.Id == id, cancellationToken); } - public async Task> GetByLevelAsync(int level, CancellationToken cancellationToken = default) + public async Task> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default) { return await _context.Lessons - .Where(l => l.Level == level) + .Include(l => l.Level) + .Where(l => l.LevelId == levelId) + .OrderBy(l => l.Order) .AsNoTracking() .ToListAsync(cancellationToken); } - public async Task> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default) + public async Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) { return await _context.Lessons - .Where(l => l.Level <= 2) + .Include(l => l.Level) + .OrderBy(l => l.Level.Order) + .ThenBy(l => l.Order) .AsNoTracking() .ToListAsync(cancellationToken); } - public async Task> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default) + public async Task GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default) { return await _context.Lessons - .Where(l => l.Level >= 4) + .Include(l => l.Level) + .Where(l => l.LevelId == levelId) + .OrderBy(l => l.Order) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task 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> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default) + { + // Get all lessons ordered by level and lesson order + var allLessons = await _context.Lessons + .Include(l => l.Level) + .OrderBy(l => l.Level.Order) + .ThenBy(l => l.Order) .AsNoTracking() .ToListAsync(cancellationToken); + + if (allLessons.Count == 0) + return new List(); + + // 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 { 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 { 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; } } diff --git a/GermanApp/Infrastructure/Data/Repositories/LevelRepository.cs b/GermanApp/Infrastructure/Data/Repositories/LevelRepository.cs new file mode 100644 index 0000000..9668c1b --- /dev/null +++ b/GermanApp/Infrastructure/Data/Repositories/LevelRepository.cs @@ -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; + +/// +/// Entity Framework Core implementation of ILevelRepository. +/// This is part of the Infrastructure layer. +/// +public class LevelRepository : ILevelRepository +{ + private readonly AppDbContext _context; + + public LevelRepository(AppDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Levels + .FirstOrDefaultAsync(l => l.Id == id, cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.Levels + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task 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 ExistsAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Levels + .AnyAsync(l => l.Id == id, cancellationToken); + } + + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) + { + return await _context.Levels + .FirstOrDefaultAsync(l => l.Code == code.ToUpperInvariant(), cancellationToken); + } + + public async Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) + { + return await _context.Levels + .OrderBy(l => l.Order) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task GetFirstLevelAsync(CancellationToken cancellationToken = default) + { + return await _context.Levels + .OrderBy(l => l.Order) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task 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); + } +} diff --git a/GermanApp/Infrastructure/Data/Repositories/UserProgressRepository.cs b/GermanApp/Infrastructure/Data/Repositories/UserProgressRepository.cs new file mode 100644 index 0000000..3d620f1 --- /dev/null +++ b/GermanApp/Infrastructure/Data/Repositories/UserProgressRepository.cs @@ -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; + +/// +/// Entity Framework Core implementation of IUserProgressRepository. +/// This is part of the Infrastructure layer. +/// +public class UserProgressRepository : IUserProgressRepository +{ + private readonly AppDbContext _context; + + public UserProgressRepository(AppDbContext context) + { + _context = context; + } + + public async Task 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> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.UserProgress + .Include(up => up.User) + .Include(up => up.Lesson) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task 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 ExistsAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.UserProgress + .AnyAsync(up => up.Id == id, cancellationToken); + } + + public async Task 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> 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> 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 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 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 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; + } +} diff --git a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs index 7fa8ece..f4c3021 100644 --- a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs +++ b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs @@ -13,7 +13,7 @@ public static class SeedDataExtension /// Seeds the database with initial data. /// /// The web application - public static void SeedDatabase(this WebApplication app) + public static async Task SeedDatabaseAsync(this WebApplication app) { using var scope = app.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -24,6 +24,18 @@ public static class SeedDataExtension // Only seed if there are no users if (!dbContext.Users.Any()) { + // Seed CEFR levels first + var levels = new[] + { + Level.Create("Beginner A1", "A1", 1), + Level.Create("Elementary A2", "A2", 2), + Level.Create("Intermediate B1", "B1", 3), + Level.Create("Upper Intermediate B2", "B2", 4), + Level.Create("Advanced C1", "C1", 5) + }; + dbContext.Levels.AddRange(levels); + await dbContext.SaveChangesAsync(); + // Seed an admin user var adminUser = User.Create( "admin", @@ -44,17 +56,17 @@ public static class SeedDataExtension // Seed some lessons var lessons = new[] { - Lesson.Create("Greetings", "Basic German greetings and introductions", 1), - Lesson.Create("Numbers", "German numbers 1-100", 1), - Lesson.Create("Grammar Basics", "Basic German grammar rules", 2), - Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2), - Lesson.Create("Advanced Grammar", "Complex German grammar", 4) + Lesson.Create(1, "Greetings", 1, "Greetings", "Basic German greetings and introductions"), + Lesson.Create(1, "Numbers", 2, "Numbers", "German numbers 1-100"), + Lesson.Create(2, "Grammar Basics", 1, "Grammar", "Basic German grammar rules"), + Lesson.Create(2, "Everyday Phrases", 2, "Phrases", "Common phrases for daily conversations"), + Lesson.Create(5, "Advanced Grammar", 1, "Grammar", "Complex German grammar") }; dbContext.Lessons.AddRange(lessons); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync(); - Console.WriteLine("Database seeded with admin user, test user, and sample lessons."); + Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons."); } } } diff --git a/GermanApp/Presentation/Endpoints/LessonsEndpoints.cs b/GermanApp/Presentation/Endpoints/LessonsEndpoints.cs index 6ee9e14..abaf9eb 100644 --- a/GermanApp/Presentation/Endpoints/LessonsEndpoints.cs +++ b/GermanApp/Presentation/Endpoints/LessonsEndpoints.cs @@ -1,5 +1,6 @@ using GermanApp.Application.DTOs; using GermanApp.Application.UseCases.Commands; +using GermanApp.Domain.Entities; using GermanApp.Domain.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -45,8 +46,13 @@ public static class LessonsEndpoints // GET /api/lessons/beginner group.MapGet("/beginner", async ([FromServices] ILessonRepository repository) => { - var lessons = await repository.GetBeginnerLessonsAsync(); - return Results.Ok(lessons.Select(l => l.ToDto())); + // Beginner lessons are A1 (level 1) and A2 (level 2) + var beginnerLessons = new List(); + var level1Lessons = await repository.GetByLevelAsync(1); + var level2Lessons = await repository.GetByLevelAsync(2); + beginnerLessons.AddRange(level1Lessons); + beginnerLessons.AddRange(level2Lessons); + return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto())); }) .WithName("GetBeginnerLessons") .WithOpenApi(operation => new(operation) @@ -58,8 +64,13 @@ public static class LessonsEndpoints // GET /api/lessons/advanced group.MapGet("/advanced", async ([FromServices] ILessonRepository repository) => { - var lessons = await repository.GetAdvancedLessonsAsync(); - return Results.Ok(lessons.Select(l => l.ToDto())); + // Advanced lessons are B2 (level 4) and C1 (level 5) + var advancedLessons = new List(); + var level4Lessons = await repository.GetByLevelAsync(4); + var level5Lessons = await repository.GetByLevelAsync(5); + advancedLessons.AddRange(level4Lessons); + advancedLessons.AddRange(level5Lessons); + return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto())); }) .WithName("GetAdvancedLessons") .WithOpenApi(operation => new(operation) @@ -110,7 +121,7 @@ public static class LessonsEndpoints return Results.NotFound(); // Map DTO to entity - existingLesson.Update(dto.Title, dto.Description, dto.Level); + existingLesson.UpdateFromDto(dto); await repository.UpdateAsync(existingLesson); return Results.Ok(existingLesson.ToDto()); diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index 4290783..d4ca4e6 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -154,7 +154,7 @@ try pattern: "api/{controller}/{action}/{id?}" ); // Seed database with initial data - app.SeedDatabase(); + await app.SeedDatabaseAsync(); // Map Clean Architecture endpoints app.MapLessonsEndpoints();