Compare commits

..

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

18 changed files with 89 additions and 1293 deletions

View file

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

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.LevelId < 1 || lesson.LevelId > 5) if (lesson.Level < 1 || lesson.Level > 5)
{ {
throw new ValidationException("Level must be between 1 and 5"); throw new ValidationException("Level must be between 1 and 5");
} }

View file

@ -1,23 +1,20 @@
namespace GermanApp.Domain.Entities; namespace GermanApp.Domain.Entities;
/// <summary> /// <summary>
/// Represents a lesson in the DeutschLernen system. /// Represents a German language lesson in the 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 bool IsActive { get; private set; } = true; public int Level { get; private set; }
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 property (EF Core will handle this) // Navigation properties would go here in EF Core
public virtual Level? Level { get; private set; } // public ICollection<VocabularyWord> Words { get; private set; }
/// <summary> /// <summary>
/// Constructor for EF Core deserialization. /// Constructor for EF Core deserialization.
@ -27,84 +24,35 @@ public class Lesson
/// <summary> /// <summary>
/// Factory method to create a new lesson. /// Factory method to create a new lesson.
/// </summary> /// </summary>
/// <param name="levelId">ID of the parent level</param> public static Lesson Create(string title, string description, int level)
/// <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's title. /// Updates the lesson details.
/// </summary> /// </summary>
public void UpdateTitle(string newTitle) public void Update(string title, string description, int level)
{ {
Title = newTitle; Title = title;
Description = description;
Level = level;
UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow;
} }
/// <summary> /// <summary>
/// Updates the lesson's topic. /// Domain behavior: Check if lesson is at beginner level.
/// </summary> /// </summary>
public void UpdateTopic(string newTopic) public bool IsBeginnerLevel() => Level <= 2;
{
Topic = newTopic;
UpdatedAt = DateTime.UtcNow;
}
/// <summary> /// <summary>
/// Updates the lesson's description. /// Domain behavior: Check if lesson is at advanced level.
/// </summary> /// </summary>
public void UpdateDescription(string newDescription) public bool IsAdvancedLevel() => Level >= 4;
{
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

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

View file

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

View file

@ -41,32 +41,6 @@ public interface IRepository<TEntity, TId> where TEntity : class
Task<bool> ExistsAsync(TId id, CancellationToken cancellationToken = default); 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>
@ -75,61 +49,15 @@ public interface ILessonRepository : IRepository<Lesson, int>
/// <summary> /// <summary>
/// Gets lessons by level. /// Gets lessons by level.
/// </summary> /// </summary>
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default); Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all lessons ordered by level and lesson order. /// Gets beginner-level lessons.
/// </summary> /// </summary>
Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets the first lesson in a level. /// Gets advanced-level lessons.
/// </summary> /// </summary>
Task<Lesson?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default); Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(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,10 +14,8 @@ 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.
@ -27,41 +25,16 @@ 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).HasMaxLength(2000); builder.Property(l => l.Description).IsRequired().HasMaxLength(2000);
builder.Property(l => l.Topic).IsRequired().HasMaxLength(100); builder.Property(l => l.Level).IsRequired();
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 =>
// { // {
@ -72,31 +45,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
// }); // });
}); });
// Configure UserProgress entity
modelBuilder.Entity<UserProgress>(builder =>
{
builder.HasKey(up => up.Id);
builder.Property(up => up.UserId).IsRequired();
builder.Property(up => up.LessonId).IsRequired();
builder.Property(up => up.IsCompleted).HasDefaultValue(false);
builder.Property(up => up.QuizScore).HasDefaultValue(0);
builder.Property(up => up.LastAttemptDate).IsRequired();
// Foreign keys
builder.HasOne(up => up.User)
.WithMany()
.HasForeignKey(up => up.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(up => up.Lesson)
.WithMany()
.HasForeignKey(up => up.LessonId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one progress record per user per lesson
builder.HasIndex(up => new { up.UserId, up.LessonId }).IsUnique();
});
// Configure User entity // Configure User entity
modelBuilder.Entity<User>(builder => modelBuilder.Entity<User>(builder =>
{ {

View file

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

View file

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

View file

@ -38,15 +38,7 @@ namespace GermanApp.Migrations
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)"); .HasColumnType("character varying(2000)");
b.Property<bool>("IsActive") b.Property<int>("Level")
.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")
@ -54,54 +46,14 @@ 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")
@ -193,54 +145,6 @@ 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)
@ -249,25 +153,6 @@ 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,14 +21,12 @@ 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);
} }
@ -58,104 +56,27 @@ public class LessonRepository : ILessonRepository
.AnyAsync(l => l.Id == id, cancellationToken); .AnyAsync(l => l.Id == id, cancellationToken);
} }
public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int levelId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default)
{ {
return await _context.Lessons return await _context.Lessons
.Include(l => l.Level) .Where(l => l.Level == level)
.Where(l => l.LevelId == levelId)
.OrderBy(l => l.Order)
.AsNoTracking() .AsNoTracking()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<IReadOnlyList<Lesson>> GetAllOrderedAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
{ {
return await _context.Lessons return await _context.Lessons
.Include(l => l.Level) .Where(l => l.Level <= 2)
.OrderBy(l => l.Level.Order)
.ThenBy(l => l.Order)
.AsNoTracking() .AsNoTracking()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<Lesson?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
{ {
return await _context.Lessons return await _context.Lessons
.Include(l => l.Level) .Where(l => l.Level >= 4)
.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

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

View file

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

View file

@ -13,7 +13,7 @@ public static class SeedDataExtension
/// Seeds the database with initial data. /// Seeds the database with initial data.
/// </summary> /// </summary>
/// <param name="app">The web application</param> /// <param name="app">The web application</param>
public static async Task SeedDatabaseAsync(this WebApplication app) public static void SeedDatabase(this WebApplication app)
{ {
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@ -24,18 +24,6 @@ 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",
@ -56,17 +44,17 @@ public static class SeedDataExtension
// Seed some lessons // Seed some lessons
var lessons = new[] var lessons = new[]
{ {
Lesson.Create(1, "Greetings", 1, "Greetings", "Basic German greetings and introductions"), Lesson.Create("Greetings", "Basic German greetings and introductions", 1),
Lesson.Create(1, "Numbers", 2, "Numbers", "German numbers 1-100"), Lesson.Create("Numbers", "German numbers 1-100", 1),
Lesson.Create(2, "Grammar Basics", 1, "Grammar", "Basic German grammar rules"), Lesson.Create("Grammar Basics", "Basic German grammar rules", 2),
Lesson.Create(2, "Everyday Phrases", 2, "Phrases", "Common phrases for daily conversations"), Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2),
Lesson.Create(5, "Advanced Grammar", 1, "Grammar", "Complex German grammar") Lesson.Create("Advanced Grammar", "Complex German grammar", 4)
}; };
dbContext.Lessons.AddRange(lessons); dbContext.Lessons.AddRange(lessons);
await dbContext.SaveChangesAsync(); dbContext.SaveChanges();
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons."); Console.WriteLine("Database seeded with admin user, test user, and sample lessons.");
} }
} }
} }

View file

@ -1,6 +1,5 @@
using GermanApp.Application.DTOs; using GermanApp.Application.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;
@ -46,13 +45,8 @@ 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) =>
{ {
// Beginner lessons are A1 (level 1) and A2 (level 2) var lessons = await repository.GetBeginnerLessonsAsync();
var beginnerLessons = new List<Lesson>(); return Results.Ok(lessons.Select(l => l.ToDto()));
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)
@ -64,13 +58,8 @@ 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) =>
{ {
// Advanced lessons are B2 (level 4) and C1 (level 5) var lessons = await repository.GetAdvancedLessonsAsync();
var advancedLessons = new List<Lesson>(); return Results.Ok(lessons.Select(l => l.ToDto()));
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)
@ -121,7 +110,7 @@ public static class LessonsEndpoints
return Results.NotFound(); return Results.NotFound();
// Map DTO to entity // Map DTO to entity
existingLesson.UpdateFromDto(dto); existingLesson.Update(dto.Title, dto.Description, dto.Level);
await repository.UpdateAsync(existingLesson); await repository.UpdateAsync(existingLesson);
return Results.Ok(existingLesson.ToDto()); return Results.Ok(existingLesson.ToDto());

View file

@ -153,15 +153,12 @@ 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 | 🚀 In Progress (Phase 1: DB & Models ✅) | Phase 1 | | 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | ⏳ Planned | Phase 1 |
| 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | ⏳ Planned | Phase 1 | | 2.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 | ✅ Complete (1.1 ✅, 1.2 ✅) | | Phase 1: Foundation | 2 weeks | 30-42h | 2 | 🚀 In Progress (1.1 ✅, 1.2 🚀) |
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | 🚀 In Progress (2.1 🚀) | | Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned |
| 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**: 🚀 In Progress > **Status**: ⏳ Planned
> **Priority**: High > **Priority**: High
> **Complexity**: High > **Complexity**: High
> **Estimate**: 10-16 hours > **Estimate**: 10-16 hours
@ -10,8 +10,6 @@
> **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
@ -134,12 +132,12 @@ Each lesson contains:
## 🚀 Implementation Plan ## 🚀 Implementation Plan
### Phase 1: Database & Models (2-3 hours) ### Phase 1: Database & Models (2-3 hours)
- [x] Create Level entity and repository - [ ] Create Level entity and repository
- [x] Create Lesson entity and repository - [ ] Create Lesson entity and repository
- [x] Create UserProgress entity and repository - [ ] Create UserProgress entity and repository
- [x] Set up relationships between entities - [ ] Set up relationships between entities
- [x] Create migrations for Levels, Lessons, UserProgress - [ ] Create migrations for Levels, Lessons, UserProgress
- [x] Seed initial A1 level and lessons - [ ] 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
@ -182,16 +180,16 @@ Each lesson contains:
## ✅ Tasks ## ✅ Tasks
### Backend ### Backend
- [x] Create Domain/Entities/Level.cs - [ ] Create Domain/Entities/Level.cs
- [x] Create Domain/Entities/Lesson.cs - [ ] Create Domain/Entities/Lesson.cs
- [x] Create Domain/Entities/UserProgress.cs - [ ] Create Domain/Entities/UserProgress.cs
- [x] Create Domain/Interfaces/ILevelRepository.cs - [ ] Create Domain/Interfaces/ILevelRepository.cs
- [x] Create Domain/Interfaces/ILessonRepository.cs - [ ] Create Domain/Interfaces/ILessonRepository.cs
- [x] Create Infrastructure/Data/Repositories/LevelRepository.cs - [ ] Create Infrastructure/Data/Repositories/LevelRepository.cs
- [x] Create Infrastructure/Data/Repositories/LessonRepository.cs - [ ] Create Infrastructure/Data/Repositories/LessonRepository.cs
- [x] Create Infrastructure/Data/Repositories/UserProgressRepository.cs - [ ] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
- [x] Create Application/DTOs/LevelDto.cs - [ ] Create Application/DTOs/LevelDto.cs
- [x] Create Application/DTOs/LessonDto.cs - [ ] 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
@ -203,11 +201,11 @@ Each lesson contains:
- [ ] Write integration tests for controllers - [ ] Write integration tests for controllers
### Database ### Database
- [x] Create migration for Levels table - [ ] Create migration for Levels table
- [x] Create migration for Lessons table - [ ] Create migration for Lessons table
- [x] Create migration for UserProgress table - [ ] Create migration for UserProgress table
- [x] Seed A1 level with initial lessons - [ ] Seed A1 level with initial lessons
- [x] Add indexes for performance - [ ] Add indexes for performance
### Business Logic ### Business Logic
- [ ] Implement LessonUnlockService - [ ] Implement LessonUnlockService