From accb06bc7c26e11c4312dede3331600eb015edf2 Mon Sep 17 00:00:00 2001 From: Lasse Rune Hansen Date: Sun, 31 May 2026 19:44:29 +0200 Subject: [PATCH] feat(backend/infra): add User entity, database migrations, and seed data - Create User entity with gamification fields (Level, Streak, Points) - Add User DbSet to AppDbContext with proper configuration - Create initial database migration with Users and Lessons tables - Add SeedData extension for initial admin user and sample lessons - Update Program.cs to use seed data method Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- GermanApp/Domain/Entities/User.cs | 78 ++++++++++++ .../Data/DbContext/AppDbContext.cs | 18 +++ .../20260531174249_InitialCreate.Designer.cs | 116 ++++++++++++++++++ .../20260531174249_InitialCreate.cs | 74 +++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 113 +++++++++++++++++ .../Data/SeedData/SeedDataExtension.cs | 60 +++++++++ GermanApp/Program.cs | 10 +- 7 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 GermanApp/Domain/Entities/User.cs create mode 100644 GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs create mode 100644 GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs create mode 100644 GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs diff --git a/GermanApp/Domain/Entities/User.cs b/GermanApp/Domain/Entities/User.cs new file mode 100644 index 0000000..7f75606 --- /dev/null +++ b/GermanApp/Domain/Entities/User.cs @@ -0,0 +1,78 @@ +namespace GermanApp.Domain.Entities; + +/// +/// Represents a user in the DeutschLernen system. +/// +public class User +{ + public int Id { get; private set; } + public string Username { get; private set; } = string.Empty; + public string Email { get; private set; } = string.Empty; + public string PasswordHash { get; private set; } = string.Empty; + public string CurrentLevel { get; private set; } = "A1"; + public int Streak { get; private set; } + public int TotalPoints { get; private set; } + public DateTime CreatedAt { get; private set; } + + /// + /// Constructor for EF Core deserialization. + /// + private User() { } + + /// + /// Factory method to create a new user. + /// + public static User Create(string username, string email, string passwordHash) + { + return new User + { + Username = username, + Email = email.ToLowerInvariant(), + PasswordHash = passwordHash, + CurrentLevel = "A1", + Streak = 0, + TotalPoints = 0, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// Updates user's gamification stats. + /// + public void AddPoints(int points) + { + TotalPoints += points; + } + + /// + /// Updates user's streak. + /// + public void UpdateStreak(int streak) + { + Streak = streak; + } + + /// + /// Updates user's current level. + /// + public void UpdateLevel(string level) + { + CurrentLevel = level; + } + + /// + /// Changes user's email. + /// + public void ChangeEmail(string newEmail) + { + Email = newEmail.ToLowerInvariant(); + } + + /// + /// Changes user's password hash. + /// + public void ChangePassword(string newPasswordHash) + { + PasswordHash = newPasswordHash; + } +} diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs index df79381..aab0cc8 100644 --- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs +++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs @@ -15,6 +15,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext // DbSets for domain entities public DbSet Lessons { get; set; } = null!; + public DbSet Users { get; set; } = null!; // Note: Value objects are not stored directly as entities. // They are owned by entities and stored as part of the entity's data. @@ -43,6 +44,23 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext // }); }); + // Configure User entity + modelBuilder.Entity(builder => + { + builder.HasKey(u => u.Id); + builder.Property(u => u.Username).IsRequired().HasMaxLength(50); + builder.Property(u => u.Email).IsRequired().HasMaxLength(100); + builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255); + builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1"); + builder.Property(u => u.Streak).HasDefaultValue(0); + builder.Property(u => u.TotalPoints).HasDefaultValue(0); + builder.Property(u => u.CreatedAt).IsRequired(); + + // Ensure unique constraints + builder.HasIndex(u => u.Username).IsUnique(); + builder.HasIndex(u => u.Email).IsUnique(); + }); + // Seed data (optional) - Note: For EF Core, we need to set properties directly // In a real application, use migrations or a separate seeding mechanism // modelBuilder.Entity().HasData( diff --git a/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs new file mode 100644 index 0000000..7667d4d --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.Designer.cs @@ -0,0 +1,116 @@ +// +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("20260531174249_InitialCreate")] + partial class InitialCreate + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentLevel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("A1"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Streak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs new file mode 100644 index 0000000..18f9d94 --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/20260531174249_InitialCreate.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GermanApp.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Lessons", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + Level = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Lessons", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Email = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + CurrentLevel = table.Column(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "A1"), + Streak = table.Column(type: "integer", nullable: false, defaultValue: 0), + TotalPoints = table.Column(type: "integer", nullable: false, defaultValue: 0), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Lessons"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..acaa18e --- /dev/null +++ b/GermanApp/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,113 @@ +// +using System; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GermanApp.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("GermanApp.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentLevel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("A1"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Streak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TotalPoints") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs new file mode 100644 index 0000000..7fa8ece --- /dev/null +++ b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs @@ -0,0 +1,60 @@ +using GermanApp.Domain.Entities; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.EntityFrameworkCore; + +namespace GermanApp.Infrastructure.Data.SeedData; + +/// +/// Extension methods for seeding the database. +/// +public static class SeedDataExtension +{ + /// + /// Seeds the database with initial data. + /// + /// The web application + public static void SeedDatabase(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Apply pending migrations + dbContext.Database.Migrate(); + + // Only seed if there are no users + if (!dbContext.Users.Any()) + { + // Seed an admin user + var adminUser = User.Create( + "admin", + "admin@deutschlernen.com", + "$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!" + ); + adminUser.UpdateLevel("C1"); + dbContext.Users.Add(adminUser); + + // Seed a test user + var testUser = User.Create( + "testuser", + "test@deutschlernen.com", + "$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Test@123!" + ); + dbContext.Users.Add(testUser); + + // Seed some lessons + var lessons = new[] + { + Lesson.Create("Greetings", "Basic German greetings and introductions", 1), + Lesson.Create("Numbers", "German numbers 1-100", 1), + Lesson.Create("Grammar Basics", "Basic German grammar rules", 2), + Lesson.Create("Everyday Phrases", "Common phrases for daily conversations", 2), + Lesson.Create("Advanced Grammar", "Complex German grammar", 4) + }; + dbContext.Lessons.AddRange(lessons); + + dbContext.SaveChanges(); + + Console.WriteLine("Database seeded with admin user, test user, and sample lessons."); + } + } +} diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index a7c0db3..d672186 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -3,6 +3,7 @@ using GermanApp.Application.UseCases.Commands; using GermanApp.Domain.Interfaces; using GermanApp.Infrastructure.Data.DbContext; using GermanApp.Infrastructure.Data.Repositories; +using GermanApp.Infrastructure.Data.SeedData; using GermanApp.Presentation.Endpoints; using Microsoft.EntityFrameworkCore; @@ -59,13 +60,8 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } -// Initialize database (in development) -if (app.Environment.IsDevelopment()) -{ - using var scope = app.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Database.EnsureCreated(); -} +// Seed database with initial data +app.SeedDatabase(); // Map Clean Architecture endpoints app.MapLessonsEndpoints();