From 649c11b21a2ef02eb6d12dfc6b3b01f1d846e3d1 Mon Sep 17 00:00:00 2001 From: Robert van Diest Date: Fri, 27 Mar 2026 16:45:20 +0100 Subject: [PATCH] refactor(persistence): separate storage models from domain entities Introduce dedicated *Record POCOs (ReservationRecord, UserRecord, WorkplaceRecord) as EF Core targets, keeping domain entities free of persistence concerns. Static mapper classes handle conversion between layers, and repositories track (domain, record) pairs to sync mutations back before saving. Domain entities gain Reconstitute() factory methods to bypass validation when rehydrating from storage. Also fixes GetByEmployeeAsync to exclude past reservations (Date >= today). Co-Authored-By: Claude Sonnet 4.6 --- .../Reservations/Reservation.cs | 18 ++- src/backend/src/Randall.Domain/Users/User.cs | 13 +- .../Randall.Domain/Workplaces/Workplace.cs | 10 +- ...27154255_SeparateStorageModels.Designer.cs | 120 ++++++++++++++++++ .../20260327154255_SeparateStorageModels.cs | 22 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 12 +- .../Persistence/AppDbContext.cs | 19 +-- .../Persistence/Mappers/ReservationMapper.cs | 34 +++++ .../Persistence/Mappers/UserMapper.cs | 33 +++++ .../Persistence/Mappers/WorkplaceMapper.cs | 23 ++++ .../Persistence/Records/ReservationRecord.cs | 14 ++ .../Persistence/Records/UserRecord.cs | 11 ++ .../Persistence/Records/WorkplaceRecord.cs | 9 ++ .../Reservations/ReservationRepository.cs | 53 ++++++-- .../Persistence/Seeding/DatabaseSeeder.cs | 5 +- .../Persistence/Users/UserRepository.cs | 68 +++++++--- .../Workplaces/WorkplaceRepository.cs | 15 ++- 17 files changed, 418 insertions(+), 61 deletions(-) create mode 100644 src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.Designer.cs create mode 100644 src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.cs create mode 100644 src/backend/src/Randall.Infrastructure/Persistence/Mappers/ReservationMapper.cs create mode 100644 src/backend/src/Randall.Infrastructure/Persistence/Mappers/UserMapper.cs create mode 100644 src/backend/src/Randall.Infrastructure/Persistence/Mappers/WorkplaceMapper.cs create mode 100644 src/backend/src/Randall.Infrastructure/Persistence/Records/ReservationRecord.cs create mode 100644 src/backend/src/Randall.Infrastructure/Persistence/Records/UserRecord.cs create mode 100644 src/backend/src/Randall.Infrastructure/Persistence/Records/WorkplaceRecord.cs diff --git a/src/backend/src/Randall.Domain/Reservations/Reservation.cs b/src/backend/src/Randall.Domain/Reservations/Reservation.cs index 421a48d..7f4b322 100644 --- a/src/backend/src/Randall.Domain/Reservations/Reservation.cs +++ b/src/backend/src/Randall.Domain/Reservations/Reservation.cs @@ -13,11 +13,21 @@ public class Reservation : Entity public ReservationStatus Status { get; private set; } public DateTime CreatedAt { get; private set; } - // EF Core constructor - private Reservation() : base() + public static Reservation Reconstitute( + Guid id, Guid workplaceId, string employeeEmail, string employeeName, + DateOnly date, ReservationStatus status, DateTime createdAt) => + new(id, workplaceId, employeeEmail, employeeName, date, status, createdAt); + + private Reservation( + Guid id, Guid workplaceId, string employeeEmail, string employeeName, + DateOnly date, ReservationStatus status, DateTime createdAt) : base(id) { - EmployeeEmail = string.Empty; - EmployeeName = string.Empty; + WorkplaceId = workplaceId; + EmployeeEmail = employeeEmail; + EmployeeName = employeeName; + Date = date; + Status = status; + CreatedAt = createdAt; } private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date) diff --git a/src/backend/src/Randall.Domain/Users/User.cs b/src/backend/src/Randall.Domain/Users/User.cs index abc10ca..422b796 100644 --- a/src/backend/src/Randall.Domain/Users/User.cs +++ b/src/backend/src/Randall.Domain/Users/User.cs @@ -10,11 +10,16 @@ public class User : Entity public bool IsApproved { get; private set; } public bool IsAdmin { get; private set; } - private User() : base() + public static User Reconstitute(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) => + new(id, email, name, passwordHash, isApproved, isAdmin); + + private User(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) : base(id) { - Email = string.Empty; - Name = string.Empty; - PasswordHash = string.Empty; + Email = email; + Name = name; + PasswordHash = passwordHash; + IsApproved = isApproved; + IsAdmin = isAdmin; } private User(string email, string name, string passwordHash, bool isAdmin) : base() diff --git a/src/backend/src/Randall.Domain/Workplaces/Workplace.cs b/src/backend/src/Randall.Domain/Workplaces/Workplace.cs index fd47377..eca5e77 100644 --- a/src/backend/src/Randall.Domain/Workplaces/Workplace.cs +++ b/src/backend/src/Randall.Domain/Workplaces/Workplace.cs @@ -8,10 +8,14 @@ public class Workplace : Entity public string Location { get; private set; } public bool IsActive { get; private set; } - private Workplace() : base() + public static Workplace Reconstitute(Guid id, string name, string location, bool isActive) => + new(id, name, location, isActive); + + private Workplace(Guid id, string name, string location, bool isActive) : base(id) { - Name = string.Empty; - Location = string.Empty; + Name = name; + Location = location; + IsActive = isActive; } public Workplace(string name, string location) : base() diff --git a/src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.Designer.cs b/src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.Designer.cs new file mode 100644 index 0000000..d23a6ae --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.Designer.cs @@ -0,0 +1,120 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Randall.Infrastructure.Persistence; + +#nullable disable + +namespace Randall.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260327154255_SeparateStorageModels")] + partial class SeparateStorageModels + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmployeeEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EmployeeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("WorkplaceId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeEmail", "Date"); + + b.HasIndex("WorkplaceId", "Date"); + + b.ToTable("Reservations", (string)null); + }); + + modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsApproved") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Workplaces", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.cs b/src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.cs new file mode 100644 index 0000000..ecb04f1 --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Migrations/20260327154255_SeparateStorageModels.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Randall.Infrastructure.Migrations +{ + /// + public partial class SeparateStorageModels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/backend/src/Randall.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/backend/src/Randall.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 5efe56b..25e1cfe 100644 --- a/src/backend/src/Randall.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/backend/src/Randall.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Randall.Infrastructure.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b => + modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -51,10 +51,10 @@ namespace Randall.Infrastructure.Migrations b.HasIndex("WorkplaceId", "Date"); - b.ToTable("Reservations"); + b.ToTable("Reservations", (string)null); }); - modelBuilder.Entity("Randall.Domain.Users.User", b => + modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -85,10 +85,10 @@ namespace Randall.Infrastructure.Migrations b.HasIndex("Email") .IsUnique(); - b.ToTable("Users"); + b.ToTable("Users", (string)null); }); - modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b => + modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -109,7 +109,7 @@ namespace Randall.Infrastructure.Migrations b.HasKey("Id"); - b.ToTable("Workplaces"); + b.ToTable("Workplaces", (string)null); }); #pragma warning restore 612, 618 } diff --git a/src/backend/src/Randall.Infrastructure/Persistence/AppDbContext.cs b/src/backend/src/Randall.Infrastructure/Persistence/AppDbContext.cs index 3d5c9f0..7104c34 100644 --- a/src/backend/src/Randall.Infrastructure/Persistence/AppDbContext.cs +++ b/src/backend/src/Randall.Infrastructure/Persistence/AppDbContext.cs @@ -1,28 +1,28 @@ using Microsoft.EntityFrameworkCore; -using Randall.Domain.Reservations; -using Randall.Domain.Users; -using Randall.Domain.Workplaces; +using Randall.Infrastructure.Persistence.Records; namespace Randall.Infrastructure.Persistence; public class AppDbContext(DbContextOptions options) : DbContext(options) { - public DbSet Workplaces => Set(); - public DbSet Reservations => Set(); - public DbSet Users => Set(); + public DbSet Workplaces => Set(); + public DbSet Reservations => Set(); + public DbSet Users => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { + entity.ToTable("Workplaces"); entity.HasKey(w => w.Id); entity.Property(w => w.Name).IsRequired().HasMaxLength(100); entity.Property(w => w.Location).IsRequired().HasMaxLength(200); entity.Property(w => w.IsActive).IsRequired(); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { + entity.ToTable("Reservations"); entity.HasKey(r => r.Id); entity.Property(r => r.WorkplaceId).IsRequired(); entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200); @@ -35,8 +35,9 @@ public class AppDbContext(DbContextOptions options) : DbContext(op entity.HasIndex(r => new { r.EmployeeEmail, r.Date }); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { + entity.ToTable("Users"); entity.HasKey(u => u.Id); entity.Property(u => u.Email).IsRequired().HasMaxLength(200); entity.Property(u => u.Name).IsRequired().HasMaxLength(200); diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Mappers/ReservationMapper.cs b/src/backend/src/Randall.Infrastructure/Persistence/Mappers/ReservationMapper.cs new file mode 100644 index 0000000..621ce91 --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Persistence/Mappers/ReservationMapper.cs @@ -0,0 +1,34 @@ +using Randall.Domain.Reservations; +using Randall.Infrastructure.Persistence.Records; + +namespace Randall.Infrastructure.Persistence.Mappers; + +public static class ReservationMapper +{ + public static Reservation ToDomain(ReservationRecord record) => + Reservation.Reconstitute( + record.Id, + record.WorkplaceId, + record.EmployeeEmail, + record.EmployeeName, + record.Date, + record.Status, + record.CreatedAt); + + public static ReservationRecord ToRecord(Reservation domain) => + new() + { + Id = domain.Id, + WorkplaceId = domain.WorkplaceId, + EmployeeEmail = domain.EmployeeEmail, + EmployeeName = domain.EmployeeName, + Date = domain.Date, + Status = domain.Status, + CreatedAt = domain.CreatedAt, + }; + + public static void SyncToRecord(Reservation domain, ReservationRecord record) + { + record.Status = domain.Status; + } +} diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Mappers/UserMapper.cs b/src/backend/src/Randall.Infrastructure/Persistence/Mappers/UserMapper.cs new file mode 100644 index 0000000..979f71a --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Persistence/Mappers/UserMapper.cs @@ -0,0 +1,33 @@ +using Randall.Domain.Users; +using Randall.Infrastructure.Persistence.Records; + +namespace Randall.Infrastructure.Persistence.Mappers; + +public static class UserMapper +{ + public static User ToDomain(UserRecord record) => + User.Reconstitute( + record.Id, + record.Email, + record.Name, + record.PasswordHash, + record.IsApproved, + record.IsAdmin); + + public static UserRecord ToRecord(User domain) => + new() + { + Id = domain.Id, + Email = domain.Email, + Name = domain.Name, + PasswordHash = domain.PasswordHash, + IsApproved = domain.IsApproved, + IsAdmin = domain.IsAdmin, + }; + + public static void SyncToRecord(User domain, UserRecord record) + { + record.IsApproved = domain.IsApproved; + record.IsAdmin = domain.IsAdmin; + } +} diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Mappers/WorkplaceMapper.cs b/src/backend/src/Randall.Infrastructure/Persistence/Mappers/WorkplaceMapper.cs new file mode 100644 index 0000000..e7af9c6 --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Persistence/Mappers/WorkplaceMapper.cs @@ -0,0 +1,23 @@ +using Randall.Domain.Workplaces; +using Randall.Infrastructure.Persistence.Records; + +namespace Randall.Infrastructure.Persistence.Mappers; + +public static class WorkplaceMapper +{ + public static Workplace ToDomain(WorkplaceRecord record) => + Workplace.Reconstitute( + record.Id, + record.Name, + record.Location, + record.IsActive); + + public static WorkplaceRecord ToRecord(Workplace domain) => + new() + { + Id = domain.Id, + Name = domain.Name, + Location = domain.Location, + IsActive = domain.IsActive, + }; +} diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Records/ReservationRecord.cs b/src/backend/src/Randall.Infrastructure/Persistence/Records/ReservationRecord.cs new file mode 100644 index 0000000..6f51210 --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Persistence/Records/ReservationRecord.cs @@ -0,0 +1,14 @@ +using Randall.Domain.Reservations; + +namespace Randall.Infrastructure.Persistence.Records; + +public class ReservationRecord +{ + public Guid Id { get; set; } + public Guid WorkplaceId { get; set; } + public string EmployeeEmail { get; set; } = string.Empty; + public string EmployeeName { get; set; } = string.Empty; + public DateOnly Date { get; set; } + public ReservationStatus Status { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Records/UserRecord.cs b/src/backend/src/Randall.Infrastructure/Persistence/Records/UserRecord.cs new file mode 100644 index 0000000..8b885fb --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Persistence/Records/UserRecord.cs @@ -0,0 +1,11 @@ +namespace Randall.Infrastructure.Persistence.Records; + +public class UserRecord +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public bool IsApproved { get; set; } + public bool IsAdmin { get; set; } +} diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Records/WorkplaceRecord.cs b/src/backend/src/Randall.Infrastructure/Persistence/Records/WorkplaceRecord.cs new file mode 100644 index 0000000..c9de0a2 --- /dev/null +++ b/src/backend/src/Randall.Infrastructure/Persistence/Records/WorkplaceRecord.cs @@ -0,0 +1,9 @@ +namespace Randall.Infrastructure.Persistence.Records; + +public class WorkplaceRecord +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public bool IsActive { get; set; } +} diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Reservations/ReservationRepository.cs b/src/backend/src/Randall.Infrastructure/Persistence/Reservations/ReservationRepository.cs index 4369d2d..58e1269 100644 --- a/src/backend/src/Randall.Infrastructure/Persistence/Reservations/ReservationRepository.cs +++ b/src/backend/src/Randall.Infrastructure/Persistence/Reservations/ReservationRepository.cs @@ -1,23 +1,40 @@ using Microsoft.EntityFrameworkCore; using Randall.Domain.Reservations; +using Randall.Infrastructure.Persistence.Mappers; +using Randall.Infrastructure.Persistence.Records; namespace Randall.Infrastructure.Persistence.Reservations; public class ReservationRepository(AppDbContext context) : IReservationRepository { - public Task GetByIdAsync(Guid id, CancellationToken ct = default) => - context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct); + private readonly Dictionary _tracked = []; - public async Task> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) => - await context.Reservations - .Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower()) + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + var record = await context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct); + if (record is null) return null; + var domain = ReservationMapper.ToDomain(record); + _tracked[domain.Id] = (domain, record); + return domain; + } + + public async Task> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var records = await context.Reservations + .Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower() && r.Date >= today) .ToListAsync(ct); + return records.Select(ReservationMapper.ToDomain).ToList(); + } public async Task> GetByWorkplaceAndDateAsync( - Guid workplaceId, DateOnly date, CancellationToken ct = default) => - await context.Reservations + Guid workplaceId, DateOnly date, CancellationToken ct = default) + { + var records = await context.Reservations .Where(r => r.WorkplaceId == workplaceId && r.Date == date) .ToListAsync(ct); + return records.Select(ReservationMapper.ToDomain).ToList(); + } public Task ExistsActiveForEmployeeOnDateAsync( string employeeEmail, DateOnly date, CancellationToken ct = default) => @@ -36,14 +53,24 @@ public class ReservationRepository(AppDbContext context) : IReservationRepositor ct); public async Task> GetActiveReservationsForDateAsync( - DateOnly date, CancellationToken ct = default) => - await context.Reservations + DateOnly date, CancellationToken ct = default) + { + var records = await context.Reservations .Where(r => r.Date == date && r.Status == ReservationStatus.Active) .ToListAsync(ct); + return records.Select(ReservationMapper.ToDomain).ToList(); + } - public async Task AddAsync(Reservation reservation, CancellationToken ct = default) => - await context.Reservations.AddAsync(reservation, ct); + public async Task AddAsync(Reservation reservation, CancellationToken ct = default) + { + var record = ReservationMapper.ToRecord(reservation); + await context.Reservations.AddAsync(record, ct); + } - public Task SaveChangesAsync(CancellationToken ct = default) => - context.SaveChangesAsync(ct); + public Task SaveChangesAsync(CancellationToken ct = default) + { + foreach (var (domain, record) in _tracked.Values) + ReservationMapper.SyncToRecord(domain, record); + return context.SaveChangesAsync(ct); + } } diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs b/src/backend/src/Randall.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs index 82fac1f..967a8a7 100644 --- a/src/backend/src/Randall.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs +++ b/src/backend/src/Randall.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Randall.Application.Common; using Randall.Domain.Users; using Randall.Domain.Workplaces; +using Randall.Infrastructure.Persistence.Mappers; namespace Randall.Infrastructure.Persistence.Seeding; @@ -25,7 +26,7 @@ public static class DatabaseSeeder // Seed workplaces only if none exist yet if (!await context.Workplaces.AnyAsync()) { - var workplaces = ExpectedWorkplaces.Select(w => new Workplace(w.Name, w.Location)); + var workplaces = ExpectedWorkplaces.Select(w => WorkplaceMapper.ToRecord(new Workplace(w.Name, w.Location))); context.Workplaces.AddRange(workplaces); await context.SaveChangesAsync(); } @@ -35,7 +36,7 @@ public static class DatabaseSeeder { var hash = passwordHasher.Hash(AdminPassword); var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!; - context.Users.Add(admin); + context.Users.Add(UserMapper.ToRecord(admin)); await context.SaveChangesAsync(); } } diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Users/UserRepository.cs b/src/backend/src/Randall.Infrastructure/Persistence/Users/UserRepository.cs index 62c89ae..311dbc3 100644 --- a/src/backend/src/Randall.Infrastructure/Persistence/Users/UserRepository.cs +++ b/src/backend/src/Randall.Infrastructure/Persistence/Users/UserRepository.cs @@ -1,37 +1,73 @@ using Microsoft.EntityFrameworkCore; using Randall.Domain.Users; +using Randall.Infrastructure.Persistence.Mappers; +using Randall.Infrastructure.Persistence.Records; namespace Randall.Infrastructure.Persistence.Users; public class UserRepository(AppDbContext context) : IUserRepository { - public Task GetByEmailAsync(string email, CancellationToken ct = default) => - context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct); + private readonly Dictionary _tracked = []; - public Task GetByIdAsync(Guid id, CancellationToken ct = default) => - context.Users.FirstOrDefaultAsync(u => u.Id == id, ct); + private User Track(UserRecord record) + { + var domain = UserMapper.ToDomain(record); + _tracked[domain.Id] = (domain, record); + return domain; + } + + public async Task GetByEmailAsync(string email, CancellationToken ct = default) + { + var record = await context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct); + return record is null ? null : Track(record); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + var record = await context.Users.FirstOrDefaultAsync(u => u.Id == id, ct); + return record is null ? null : Track(record); + } public Task ExistsByEmailAsync(string email, CancellationToken ct = default) => context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct); - public Task> GetPendingAsync(CancellationToken ct = default) => - context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct); + public async Task> GetPendingAsync(CancellationToken ct = default) + { + var records = await context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct); + return records.Select(UserMapper.ToDomain).ToList(); + } - public Task> GetAllAsync(CancellationToken ct = default) => - context.Users.OrderBy(u => u.Name).ToListAsync(ct); + public async Task> GetAllAsync(CancellationToken ct = default) + { + var records = await context.Users.OrderBy(u => u.Name).ToListAsync(ct); + return records.Select(UserMapper.ToDomain).ToList(); + } public Task CountAdminsAsync(CancellationToken ct = default) => context.Users.CountAsync(u => u.IsAdmin); - public Task> GetAllNonAdminAsync(CancellationToken ct = default) => - context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct); + public async Task> GetAllNonAdminAsync(CancellationToken ct = default) + { + var records = await context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct); + return records.Select(UserMapper.ToDomain).ToList(); + } - public async Task AddAsync(User user, CancellationToken ct = default) => - await context.Users.AddAsync(user, ct); + public async Task AddAsync(User user, CancellationToken ct = default) + { + var record = UserMapper.ToRecord(user); + await context.Users.AddAsync(record, ct); + } - public void Delete(User user) => - context.Users.Remove(user); + public void Delete(User user) + { + if (_tracked.TryGetValue(user.Id, out var entry)) + context.Users.Remove(entry.Record); + } - public Task SaveChangesAsync(CancellationToken ct = default) => - context.SaveChangesAsync(ct); + public Task SaveChangesAsync(CancellationToken ct = default) + { + foreach (var (domain, record) in _tracked.Values) + UserMapper.SyncToRecord(domain, record); + return context.SaveChangesAsync(ct); + } } diff --git a/src/backend/src/Randall.Infrastructure/Persistence/Workplaces/WorkplaceRepository.cs b/src/backend/src/Randall.Infrastructure/Persistence/Workplaces/WorkplaceRepository.cs index 9a69c07..6ef7da3 100644 --- a/src/backend/src/Randall.Infrastructure/Persistence/Workplaces/WorkplaceRepository.cs +++ b/src/backend/src/Randall.Infrastructure/Persistence/Workplaces/WorkplaceRepository.cs @@ -1,13 +1,20 @@ using Microsoft.EntityFrameworkCore; using Randall.Domain.Workplaces; +using Randall.Infrastructure.Persistence.Mappers; namespace Randall.Infrastructure.Persistence.Workplaces; public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository { - public Task GetByIdAsync(Guid id, CancellationToken ct = default) => - context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct); + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + var record = await context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct); + return record is null ? null : WorkplaceMapper.ToDomain(record); + } - public async Task> GetAllActiveAsync(CancellationToken ct = default) => - await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct); + public async Task> GetAllActiveAsync(CancellationToken ct = default) + { + var records = await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct); + return records.Select(WorkplaceMapper.ToDomain).ToList(); + } }