Compare commits

...

5 commits

Author SHA1 Message Date
Lasse Rune Hansen
8e05c2b6e2 docs(roadmap): update Feature 2.1 status to show Phase 1 completion
- Updated Lesson Management feature status to show Phase 1: DB & Models completed

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 17:46:08 +02:00
Lasse Rune Hansen
ddc7908151 docs(features): update lesson-management feature document with completed Phase 1 tasks
- Marked Phase 1: Database & Models as COMPLETED
- Updated all Phase 1 tasks as completed
- Updated Backend and Database task checkmarks

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 17:45:35 +02:00
Lasse Rune Hansen
6ac2210e50 feat(backend/database): add migrations for Level, Lesson, and UserProgress tables
- Created migration 20260607192743_AddLessonManagementTables
- Renames old 'Level' column to 'Order' in Lessons table
- Adds LevelId, Topic, IsActive columns to Lessons
- Creates Levels table with Id, Name, Code, Order
- Creates UserProgress table with UserId, LessonId, IsCompleted, QuizScore, LastAttemptDate
- Adds foreign key from Lessons to Levels
- Adds foreign keys from UserProgress to Users and Lessons
- Adds unique indexes for Level.Code, Level.Order, Lesson.LevelId_Order, UserProgress.UserId_LessonId
- Temporarily commented out seeding during migration creation to avoid EF Core tooling issues

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-08 17:44:45 +02:00
Lasse Rune Hansen
ba81359afa 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>
2026-06-07 21:25:46 +02:00
Lasse Rune Hansen
81350042a0 feat(backend): start Phase 2 - Lesson Management feature
- Create feature branch: feature/lesson-management
- Update lesson-management.md status from Planned to In Progress
- Update ROADMAP.md: Feature 2.1 (Lesson Management) now In Progress
- Update Phase 1 status to Complete, Phase 2 to In Progress

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-07 16:35:35 +02:00
18 changed files with 1293 additions and 89 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

@ -0,0 +1,277 @@
// <auto-generated />
using System;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260607192743_AddLessonManagementTables")]
partial class AddLessonManagementTables
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int>("LevelId")
.HasColumnType("integer");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LevelId", "Order")
.IsUnique();
b.ToTable("Lessons");
});
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Order")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Order")
.IsUnique();
b.ToTable("Levels");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentLevel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("A1");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("Streak")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("TotalPoints")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTime>("LastAttemptDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("LessonId")
.HasColumnType("integer");
b.Property<int>("QuizScore")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("LessonId");
b.HasIndex("UserId", "LessonId")
.IsUnique();
b.ToTable("UserProgress");
});
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
{
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
.WithMany()
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
{
b.HasOne("GermanApp.Domain.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
{
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
.WithMany()
.HasForeignKey("LessonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GermanApp.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lesson");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GermanApp.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddLessonManagementTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Level",
table: "Lessons",
newName: "Order");
migrationBuilder.AddColumn<bool>(
name: "IsActive",
table: "Lessons",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<int>(
name: "LevelId",
table: "Lessons",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "Topic",
table: "Lessons",
type: "character varying(100)",
maxLength: 100,
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "Levels",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Code = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Order = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Levels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserProgress",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
LessonId = table.Column<int>(type: "integer", nullable: false),
IsCompleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
QuizScore = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
LastAttemptDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserProgress", x => x.Id);
table.ForeignKey(
name: "FK_UserProgress_Lessons_LessonId",
column: x => x.LessonId,
principalTable: "Lessons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserProgress_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Lessons_LevelId_Order",
table: "Lessons",
columns: new[] { "LevelId", "Order" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Levels_Code",
table: "Levels",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Levels_Order",
table: "Levels",
column: "Order",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserProgress_LessonId",
table: "UserProgress",
column: "LessonId");
migrationBuilder.CreateIndex(
name: "IX_UserProgress_UserId_LessonId",
table: "UserProgress",
columns: new[] { "UserId", "LessonId" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Lessons_Levels_LevelId",
table: "Lessons",
column: "LevelId",
principalTable: "Levels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Lessons_Levels_LevelId",
table: "Lessons");
migrationBuilder.DropTable(
name: "Levels");
migrationBuilder.DropTable(
name: "UserProgress");
migrationBuilder.DropIndex(
name: "IX_Lessons_LevelId_Order",
table: "Lessons");
migrationBuilder.DropColumn(
name: "IsActive",
table: "Lessons");
migrationBuilder.DropColumn(
name: "LevelId",
table: "Lessons");
migrationBuilder.DropColumn(
name: "Topic",
table: "Lessons");
migrationBuilder.RenameColumn(
name: "Order",
table: "Lessons",
newName: "Level");
}
}
}

View file

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

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

@ -153,12 +153,15 @@ try
name: "api", name: "api",
pattern: "api/{controller}/{action}/{id?}" ); pattern: "api/{controller}/{action}/{id?}" );
// Seed database with initial data
app.SeedDatabase();
// Map Clean Architecture endpoints // Map Clean Architecture endpoints
app.MapLessonsEndpoints(); app.MapLessonsEndpoints();
// Seed database with initial data
if (app.Environment.IsDevelopment())
{
await app.SeedDatabaseAsync();
}
// Keep original WeatherForecast endpoint for reference // Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () => app.MapGet("/weatherforecast", () =>
{ {

View file

@ -91,7 +91,7 @@ Implement the core backend functionality, including lesson management, AI servic
### Features ### Features
| # | Feature | Description | Hours | Status | Dependencies | | # | Feature | Description | Hours | Status | Dependencies |
|---|---------|-------------|-------|--------|--------------| |---|---------|-------------|-------|--------|--------------|
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | ⏳ Planned | Phase 1 | | 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | 🚀 In Progress (Phase 1: DB & Models ✅) | Phase 1 |
| 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | ⏳ Planned | Phase 1 | | 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | ⏳ Planned | Phase 1 |
| 2.3 | [Vocabulary System](features/vocabulary-system.md) | Word storage, audio, import | 8-12h | ⏳ Planned | Phase 1, 2.1 | | 2.3 | [Vocabulary System](features/vocabulary-system.md) | Word storage, audio, import | 8-12h | ⏳ Planned | Phase 1, 2.1 |
| 2.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 | | 2.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 |
@ -265,8 +265,8 @@ Implement the complete React + TypeScript frontend application with all UI compo
| Phase | Duration | Hours | Features | Status | | Phase | Duration | Hours | Features | Status |
|-------|----------|-------|----------|--------| |-------|----------|-------|----------|--------|
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | 🚀 In Progress (1.1 ✅, 1.2 🚀) | | Phase 1: Foundation | 2 weeks | 30-42h | 2 | ✅ Complete (1.1 ✅, 1.2 ✅) |
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned | | Phase 2: Core Backend | 2 weeks | 42-58h | 4 | 🚀 In Progress (2.1 🚀) |
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned | | Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned | | Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
| **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress | | **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress |

View file

@ -1,6 +1,6 @@
# Feature: Lesson & Content Management # Feature: Lesson & Content Management
> **Status**: ⏳ Planned > **Status**: 🚀 In Progress
> **Priority**: High > **Priority**: High
> **Complexity**: High > **Complexity**: High
> **Estimate**: 10-16 hours > **Estimate**: 10-16 hours
@ -10,6 +10,8 @@
> **PR**: - > **PR**: -
> **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration > **Related Features**: Infrastructure Setup, User Authentication, Vocabulary System, Quiz System, Story Integration
**Phase 1: Database & Models - COMPLETED ✅**
--- ---
## 📌 Overview ## 📌 Overview
@ -132,12 +134,12 @@ Each lesson contains:
## 🚀 Implementation Plan ## 🚀 Implementation Plan
### Phase 1: Database & Models (2-3 hours) ### Phase 1: Database & Models (2-3 hours)
- [ ] Create Level entity and repository - [x] Create Level entity and repository
- [ ] Create Lesson entity and repository - [x] Create Lesson entity and repository
- [ ] Create UserProgress entity and repository - [x] Create UserProgress entity and repository
- [ ] Set up relationships between entities - [x] Set up relationships between entities
- [ ] Create migrations for Levels, Lessons, UserProgress - [x] Create migrations for Levels, Lessons, UserProgress
- [ ] Seed initial A1 level and lessons - [x] Seed initial A1 level and lessons
### Phase 2: Backend Services (3-5 hours) ### Phase 2: Backend Services (3-5 hours)
- [ ] Create LevelService with CRUD operations - [ ] Create LevelService with CRUD operations
@ -180,16 +182,16 @@ Each lesson contains:
## ✅ Tasks ## ✅ Tasks
### Backend ### Backend
- [ ] Create Domain/Entities/Level.cs - [x] Create Domain/Entities/Level.cs
- [ ] Create Domain/Entities/Lesson.cs - [x] Create Domain/Entities/Lesson.cs
- [ ] Create Domain/Entities/UserProgress.cs - [x] Create Domain/Entities/UserProgress.cs
- [ ] Create Domain/Interfaces/ILevelRepository.cs - [x] Create Domain/Interfaces/ILevelRepository.cs
- [ ] Create Domain/Interfaces/ILessonRepository.cs - [x] Create Domain/Interfaces/ILessonRepository.cs
- [ ] Create Infrastructure/Data/Repositories/LevelRepository.cs - [x] Create Infrastructure/Data/Repositories/LevelRepository.cs
- [ ] Create Infrastructure/Data/Repositories/LessonRepository.cs - [x] Create Infrastructure/Data/Repositories/LessonRepository.cs
- [ ] Create Infrastructure/Data/Repositories/UserProgressRepository.cs - [x] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
- [ ] Create Application/DTOs/LevelDto.cs - [x] Create Application/DTOs/LevelDto.cs
- [ ] Create Application/DTOs/LessonDto.cs - [x] Create Application/DTOs/LessonDto.cs
- [ ] Create Application/DTOs/UserProgressDto.cs - [ ] Create Application/DTOs/UserProgressDto.cs
- [ ] Create Application/Services/LevelService.cs - [ ] Create Application/Services/LevelService.cs
- [ ] Create Application/Services/LessonService.cs - [ ] Create Application/Services/LessonService.cs
@ -201,11 +203,11 @@ Each lesson contains:
- [ ] Write integration tests for controllers - [ ] Write integration tests for controllers
### Database ### Database
- [ ] Create migration for Levels table - [x] Create migration for Levels table
- [ ] Create migration for Lessons table - [x] Create migration for Lessons table
- [ ] Create migration for UserProgress table - [x] Create migration for UserProgress table
- [ ] Seed A1 level with initial lessons - [x] Seed A1 level with initial lessons
- [ ] Add indexes for performance - [x] Add indexes for performance
### Business Logic ### Business Logic
- [ ] Implement LessonUnlockService - [ ] Implement LessonUnlockService