DeutschLernen/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
Lasse Rune Hansen 50f1b8a8dc feat(backend): Implement mandatory authentication and admin module
- Add Role property to User entity with migration
- Create BootstrapController for first admin user creation
- Remove [AllowAnonymous] from all learning content controllers
- Create AdminController with admin-only endpoints
- Create AdminService for user management
- Create UserReportService for progress reports
- Add UserRepository implementation
- Update AuthService with role support

feat(frontend): Implement authentication system
- Add AuthStore with React context for auth state management
- Create Login, Register, Landing, and Home pages
- Add ProtectedRoute and AdminRoute components
- Create Auth API types and client
- Configure Vite with @/ path alias
- Add comprehensive CSS styles for auth and landing pages

BREAKING CHANGE: All learning content now requires authentication.
Users must register and sign in before accessing lessons, quizzes, and stories.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-14 12:42:15 +02:00

293 lines
13 KiB
C#

using GermanApp.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.DbContext;
/// <summary>
/// Entity Framework Core database context.
/// This is part of the Infrastructure layer.
/// </summary>
public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
// DbSets for domain entities
public virtual DbSet<Level> Levels { get; set; } = null!;
public virtual DbSet<Lesson> Lessons { get; set; } = null!;
public virtual DbSet<User> Users { get; set; } = null!;
public virtual DbSet<UserProgress> UserProgress { get; set; } = null!;
public virtual DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
public virtual DbSet<Quiz> Quizzes { get; set; } = null!;
public virtual DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
public virtual DbSet<QuizOption> QuizOptions { get; set; } = null!;
public virtual DbSet<StorySegment> StorySegments { get; set; } = null!;
public virtual DbSet<StoryProgress> StoryProgress { 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.
protected override void OnModelCreating(ModelBuilder 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
modelBuilder.Entity<Lesson>(builder =>
{
builder.HasKey(l => l.Id);
builder.Property(l => l.Title).IsRequired().HasMaxLength(200);
builder.Property(l => l.Description).HasMaxLength(2000);
builder.Property(l => l.Topic).IsRequired().HasMaxLength(100);
builder.Property(l => l.LevelId).IsRequired();
builder.Property(l => l.Order).IsRequired();
builder.Property(l => l.IsActive).HasDefaultValue(true);
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.UpdatedAt).IsRequired(false);
// Foreign key to Level with unique constraint per level
builder.HasOne(l => l.Level)
.WithMany()
.HasForeignKey(l => l.LevelId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one lesson per level per order
builder.HasIndex(l => new { l.LevelId, l.Order }).IsUnique();
// Value object: GermanWord would be configured as an owned entity
// builder.OwnsMany(l => l.Words, wordBuilder =>
// {
// wordBuilder.Property(w => w.Value).HasColumnName("WordValue");
// wordBuilder.Property(w => w.Translation).HasColumnName("Translation");
// wordBuilder.Property(w => w.Article).HasColumnName("Article");
// wordBuilder.Property(w => w.PartOfSpeech).HasColumnName("PartOfSpeech");
// });
});
// 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
modelBuilder.Entity<User>(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.Role).HasMaxLength(20).HasDefaultValue("User");
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();
});
// Configure RefreshToken entity
modelBuilder.Entity<RefreshToken>(builder =>
{
builder.HasKey(r => r.Id);
builder.Property(r => r.UserId).IsRequired();
builder.Property(r => r.Token).IsRequired().HasMaxLength(255);
builder.Property(r => r.ExpiresAt).IsRequired();
builder.Property(r => r.IsActive).HasDefaultValue(true);
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RevokedAt).IsRequired(false);
// Foreign key to User
builder.HasOne<User>()
.WithMany()
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure Quiz entity
modelBuilder.Entity<Quiz>(builder =>
{
builder.HasKey(q => q.Id);
builder.Property(q => q.LessonId).IsRequired();
builder.Property(q => q.Title).IsRequired().HasMaxLength(200);
builder.Property(q => q.Description).HasMaxLength(2000);
builder.Property(q => q.PassingScore).IsRequired().HasDefaultValue(80);
builder.Property(q => q.TimeLimitMinutes).HasDefaultValue(0);
builder.Property(q => q.IsActive).HasDefaultValue(true);
builder.Property(q => q.ShuffleQuestions).HasDefaultValue(true);
builder.Property(q => q.CreatedAt).IsRequired();
builder.Property(q => q.UpdatedAt).IsRequired(false);
// Foreign key to Lesson
builder.HasOne(q => q.Lesson)
.WithMany()
.HasForeignKey(q => q.LessonId)
.OnDelete(DeleteBehavior.Cascade);
// Navigation to questions
builder.HasMany(q => q.Questions)
.WithOne(qq => qq.Quiz)
.HasForeignKey(qq => qq.QuizId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure QuizQuestion entity
modelBuilder.Entity<QuizQuestion>(builder =>
{
builder.HasKey(q => q.Id);
builder.Property(q => q.QuizId).IsRequired();
builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000);
builder.Property(q => q.Type).IsRequired();
builder.Property(q => q.CorrectAnswer).HasMaxLength(1000);
builder.Property(q => q.Points).IsRequired().HasDefaultValue(1);
builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3);
builder.Property(q => q.Order).IsRequired().HasDefaultValue(1);
builder.Property(q => q.IsActive).HasDefaultValue(true);
builder.Property(q => q.CreatedAt).IsRequired();
builder.Property(q => q.UpdatedAt).IsRequired(false);
// Foreign key to Quiz
builder.HasOne(q => q.Quiz)
.WithMany(qq => qq.Questions)
.HasForeignKey(q => q.QuizId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one question per quiz per order
builder.HasIndex(q => new { q.QuizId, q.Order }).IsUnique();
// Navigation to options
builder.HasMany(q => q.Options)
.WithOne(o => o.QuizQuestion)
.HasForeignKey(o => o.QuizQuestionId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure QuizOption entity
modelBuilder.Entity<QuizOption>(builder =>
{
builder.HasKey(o => o.Id);
builder.Property(o => o.QuizQuestionId).IsRequired();
builder.Property(o => o.Text).IsRequired().HasMaxLength(1000);
builder.Property(o => o.IsCorrect).IsRequired().HasDefaultValue(false);
builder.Property(o => o.Order).IsRequired().HasDefaultValue(1);
// Foreign key to QuizQuestion
builder.HasOne(o => o.QuizQuestion)
.WithMany(q => q.Options)
.HasForeignKey(o => o.QuizQuestionId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one option per question per order
builder.HasIndex(o => new { o.QuizQuestionId, o.Order }).IsUnique();
});
// Configure StorySegment entity
modelBuilder.Entity<StorySegment>(builder =>
{
builder.HasKey(s => s.Id);
builder.Property(s => s.LevelId).IsRequired();
builder.Property(s => s.LessonId).IsRequired(false);
builder.Property(s => s.Content).IsRequired();
builder.Property(s => s.AudioUrl).HasMaxLength(255).IsRequired(false);
builder.Property(s => s.Order).IsRequired();
builder.Property(s => s.Title).IsRequired().HasMaxLength(200);
builder.Property(s => s.Theme).IsRequired().HasMaxLength(100);
builder.Property(s => s.EstimatedReadingMinutes).IsRequired().HasDefaultValue(2);
builder.Property(s => s.IsActive).HasDefaultValue(true);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.UpdatedAt).IsRequired(false);
// Foreign key to Level
builder.HasOne(s => s.Level)
.WithMany()
.HasForeignKey(s => s.LevelId)
.OnDelete(DeleteBehavior.Cascade);
// Foreign key to Lesson (optional)
builder.HasOne(s => s.Lesson)
.WithMany()
.HasForeignKey(s => s.LessonId)
.OnDelete(DeleteBehavior.SetNull);
// Unique constraint: one segment per level per order
builder.HasIndex(s => new { s.LevelId, s.Order }).IsUnique();
});
// Configure StoryProgress entity
modelBuilder.Entity<StoryProgress>(builder =>
{
builder.HasKey(sp => sp.Id);
builder.Property(sp => sp.UserId).IsRequired();
builder.Property(sp => sp.LevelId).IsRequired();
builder.Property(sp => sp.StorySegmentId).IsRequired();
builder.Property(sp => sp.IsCompleted).HasDefaultValue(false);
builder.Property(sp => sp.UnlockedAt).IsRequired();
builder.Property(sp => sp.CompletedAt).IsRequired(false);
builder.Property(sp => sp.CreatedAt).IsRequired();
builder.Property(sp => sp.UpdatedAt).IsRequired(false);
// Foreign key to User
builder.HasOne(sp => sp.User)
.WithMany()
.HasForeignKey(sp => sp.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Foreign key to Level
builder.HasOne(sp => sp.Level)
.WithMany()
.HasForeignKey(sp => sp.LevelId)
.OnDelete(DeleteBehavior.Cascade);
// Foreign key to StorySegment
builder.HasOne(sp => sp.StorySegment)
.WithMany()
.HasForeignKey(sp => sp.StorySegmentId)
.OnDelete(DeleteBehavior.Cascade);
// Unique constraint: one progress record per user per segment
builder.HasIndex(sp => new { sp.UserId, sp.StorySegmentId }).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<Lesson>().HasData(
// new { Id = 1, Title = "Greetings", Description = "Basic German greetings", Level = 1, CreatedAt = DateTime.UtcNow },
// new { Id = 2, Title = "Numbers", Description = "German numbers 1-100", Level = 1, CreatedAt = DateTime.UtcNow },
// new { Id = 3, Title = "Grammar Basics", Description = "Basic German grammar", Level = 2, CreatedAt = DateTime.UtcNow }
// );
}
}