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 <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-06-07 21:25:46 +02:00
parent 81350042a0
commit ba81359afa
13 changed files with 709 additions and 61 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a CEFR level in the DeutschLernen system (A1, A2, B1, B2, C1).
/// </summary>
public class Level
{
public int Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Code { get; private set; } = string.Empty;
public int Order { get; private set; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private Level() { }
/// <summary>
/// Factory method to create a new level.
/// </summary>
/// <param name="name">Display name of the level (e.g., "Beginner A1")</param>
/// <param name="code">Short code for the level (e.g., "A1")</param>
/// <param name="order">Sort order of the level</param>
public static Level Create(string name, string code, int order)
{
return new Level
{
Name = name,
Code = code.ToUpperInvariant(),
Order = order
};
}
/// <summary>
/// Updates the level's display name.
/// </summary>
public void UpdateName(string newName)
{
Name = newName;
}
/// <summary>
/// Updates the level's code.
/// </summary>
public void UpdateCode(string newCode)
{
Code = newCode.ToUpperInvariant();
}
/// <summary>
/// Updates the level's sort order.
/// </summary>
public void UpdateOrder(int newOrder)
{
Order = newOrder;
}
}

View file

@ -0,0 +1,82 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a user's progress through lessons.
/// Tracks completion status and quiz scores.
/// </summary>
public class UserProgress
{
public int Id { get; private set; }
public int UserId { get; private set; }
public int LessonId { get; private set; }
public bool IsCompleted { get; private set; }
public int QuizScore { get; private set; }
public DateTime LastAttemptDate { get; private set; }
// Navigation properties
public virtual User? User { get; private set; }
public virtual Lesson? Lesson { get; private set; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private UserProgress() { }
/// <summary>
/// Factory method to create a new user progress record.
/// </summary>
/// <param name="userId">ID of the user</param>
/// <param name="lessonId">ID of the lesson</param>
public static UserProgress Create(int userId, int lessonId)
{
return new UserProgress
{
UserId = userId,
LessonId = lessonId,
IsCompleted = false,
QuizScore = 0,
LastAttemptDate = DateTime.UtcNow
};
}
/// <summary>
/// Marks the lesson as completed.
/// </summary>
/// <param name="quizScore">The score achieved on the quiz (0-100)</param>
public void MarkAsCompleted(int quizScore)
{
if (quizScore < 0 || quizScore > 100)
throw new ArgumentOutOfRangeException(nameof(quizScore), "Score must be between 0 and 100");
IsCompleted = true;
QuizScore = quizScore;
LastAttemptDate = DateTime.UtcNow;
}
/// <summary>
/// Updates the quiz score without marking as completed.
/// </summary>
public void UpdateQuizScore(int quizScore)
{
if (quizScore < 0 || quizScore > 100)
throw new ArgumentOutOfRangeException(nameof(quizScore), "Score must be between 0 and 100");
QuizScore = quizScore;
LastAttemptDate = DateTime.UtcNow;
}
/// <summary>
/// Resets the progress (e.g., when user wants to redo a lesson).
/// </summary>
public void Reset()
{
IsCompleted = false;
QuizScore = 0;
LastAttemptDate = DateTime.UtcNow;
}
/// <summary>
/// Checks if the user passed the lesson (80% or higher).
/// </summary>
public bool HasPassed() => QuizScore >= 80;
}

View file

@ -41,6 +41,32 @@ public interface IRepository<TEntity, TId> where TEntity : class
Task<bool> ExistsAsync(TId id, CancellationToken cancellationToken = default); Task<bool> ExistsAsync(TId id, CancellationToken cancellationToken = default);
} }
/// <summary>
/// Repository interface for Level entities.
/// </summary>
public interface ILevelRepository : IRepository<Level, int>
{
/// <summary>
/// Gets a level by its code (e.g., "A1", "B2").
/// </summary>
Task<Level?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all levels ordered by their sort order.
/// </summary>
Task<IReadOnlyList<Level>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the first level (lowest order number).
/// </summary>
Task<Level?> GetFirstLevelAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the next level after the specified one.
/// </summary>
Task<Level?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default);
}
/// <summary> /// <summary>
/// Repository interface for Lesson entities. /// Repository interface for Lesson entities.
/// </summary> /// </summary>
@ -49,15 +75,61 @@ public interface ILessonRepository : IRepository<Lesson, int>
/// <summary> /// <summary>
/// Gets lessons by level. /// Gets lessons by level.
/// </summary> /// </summary>
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default); Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets beginner-level lessons. /// Gets all lessons ordered by level and lesson order.
/// </summary> /// </summary>
Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets advanced-level lessons. /// Gets the first lesson in a level.
/// </summary> /// </summary>
Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default); Task<Lesson?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the next lesson after the specified one (in the same level).
/// </summary>
Task<Lesson?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets lessons that the user can access (completed previous lessons or first in level).
/// </summary>
Task<IReadOnlyList<Lesson>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for UserProgress entities.
/// </summary>
public interface IUserProgressRepository : IRepository<UserProgress, int>
{
/// <summary>
/// Gets user progress for a specific user and lesson.
/// </summary>
Task<UserProgress?> GetByUserAndLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all progress records for a user.
/// </summary>
Task<IReadOnlyList<UserProgress>> GetByUserAsync(int userId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets progress for all lessons in a specific level for a user.
/// </summary>
Task<IReadOnlyList<UserProgress>> GetByUserAndLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a user has completed a lesson (with 80% or higher score).
/// </summary>
Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the user's average score for a level.
/// </summary>
Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the percentage of lessons completed in a level.
/// </summary>
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
} }

View file

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

View file

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

View file

@ -0,0 +1,95 @@
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.Repositories;
/// <summary>
/// Entity Framework Core implementation of ILevelRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class LevelRepository : ILevelRepository
{
private readonly AppDbContext _context;
public LevelRepository(AppDbContext context)
{
_context = context;
}
public async Task<Level?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Levels
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Level>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Levels
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<Level> AddAsync(Level entity, CancellationToken cancellationToken = default)
{
await _context.Levels.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(Level entity, CancellationToken cancellationToken = default)
{
_context.Levels.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(Level entity, CancellationToken cancellationToken = default)
{
_context.Levels.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Levels
.AnyAsync(l => l.Id == id, cancellationToken);
}
public async Task<Level?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
{
return await _context.Levels
.FirstOrDefaultAsync(l => l.Code == code.ToUpperInvariant(), cancellationToken);
}
public async Task<IReadOnlyList<Level>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
{
return await _context.Levels
.OrderBy(l => l.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<Level?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
{
return await _context.Levels
.OrderBy(l => l.Order)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<Level?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
{
var currentLevel = await _context.Levels
.Where(l => l.Id == currentLevelId)
.Select(l => l.Order)
.FirstOrDefaultAsync(cancellationToken);
if (currentLevel == default)
return null;
return await _context.Levels
.Where(l => l.Order > currentLevel)
.OrderBy(l => l.Order)
.FirstOrDefaultAsync(cancellationToken);
}
}

View file

@ -0,0 +1,127 @@
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.Repositories;
/// <summary>
/// Entity Framework Core implementation of IUserProgressRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class UserProgressRepository : IUserProgressRepository
{
private readonly AppDbContext _context;
public UserProgressRepository(AppDbContext context)
{
_context = context;
}
public async Task<UserProgress?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.Include(up => up.User)
.Include(up => up.Lesson)
.FirstOrDefaultAsync(up => up.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<UserProgress>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.Include(up => up.User)
.Include(up => up.Lesson)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<UserProgress> AddAsync(UserProgress entity, CancellationToken cancellationToken = default)
{
await _context.UserProgress.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(UserProgress entity, CancellationToken cancellationToken = default)
{
_context.UserProgress.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(UserProgress entity, CancellationToken cancellationToken = default)
{
_context.UserProgress.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.AnyAsync(up => up.Id == id, cancellationToken);
}
public async Task<UserProgress?> GetByUserAndLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.Include(up => up.User)
.Include(up => up.Lesson)
.FirstOrDefaultAsync(up => up.UserId == userId && up.LessonId == lessonId, cancellationToken);
}
public async Task<IReadOnlyList<UserProgress>> GetByUserAsync(int userId, CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.Include(up => up.Lesson)
.ThenInclude(l => l.Level)
.Where(up => up.UserId == userId)
.OrderBy(up => up.Lesson.Level!.Order)
.ThenBy(up => up.Lesson.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<UserProgress>> GetByUserAndLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.Include(up => up.Lesson)
.Where(up => up.UserId == userId && up.Lesson.LevelId == levelId)
.OrderBy(up => up.Lesson.Order)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
return await _context.UserProgress
.AnyAsync(up => up.UserId == userId && up.LessonId == lessonId && up.IsCompleted, cancellationToken);
}
public async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
var progresses = await _context.UserProgress
.Where(up => up.UserId == userId && up.Lesson.LevelId == levelId && up.QuizScore > 0)
.Select(up => up.QuizScore)
.ToListAsync(cancellationToken);
if (progresses.Count == 0)
return 0;
return progresses.Average();
}
public async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
// Get total lessons in the level
var totalLessons = await _context.Lessons
.CountAsync(l => l.LevelId == levelId, cancellationToken);
if (totalLessons == 0)
return 0;
// Get completed lessons count for user in this level
var completedCount = await _context.UserProgress
.CountAsync(up => up.UserId == userId && up.Lesson.LevelId == levelId && up.IsCompleted, cancellationToken);
return (double)completedCount / totalLessons * 100;
}
}

View file

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

View file

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

View file

@ -154,7 +154,7 @@ try
pattern: "api/{controller}/{action}/{id?}" ); pattern: "api/{controller}/{action}/{id?}" );
// Seed database with initial data // Seed database with initial data
app.SeedDatabase(); await app.SeedDatabaseAsync();
// Map Clean Architecture endpoints // Map Clean Architecture endpoints
app.MapLessonsEndpoints(); app.MapLessonsEndpoints();