# Member & User Management — Part 1: Backend Infrastructure (Tasks 1–5) > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task. **Goal:** Add base entity classes, audit interceptor, Member/FamilyUnit entities, AppDbContext updates, EF migration, and all DTOs. **Architecture:** `AuditableEntity` / `SoftDeleteEntity` base classes + `AuditSaveChangesInterceptor` handle all audit fields and soft-deletes automatically. Member and FamilyUnit entities are added to `AppDbContext`. DTOs are flat classes with data annotations. **Tech Stack:** C# .NET 8, EF Core 8, ASP.NET Identity, PostgreSQL, xUnit, Moq **Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md` --- ## File Structure ``` API/ROLAC.API/ Entities/ Base/ AuditableEntity.cs ← NEW SoftDeleteEntity.cs ← NEW Member.cs ← NEW FamilyUnit.cs ← NEW Data/ AppDbContext.cs ← MODIFY (add DbSets + config) Interceptors/ AuditSaveChangesInterceptor.cs ← NEW DTOs/ Shared/ PagedResult.cs ← NEW Members/ MemberListItemDto.cs ← NEW MemberDto.cs ← NEW CreateMemberRequest.cs ← NEW UpdateMemberRequest.cs ← NEW Users/ UserListItemDto.cs ← NEW UserDto.cs ← NEW CreateUserRequest.cs ← NEW CreateUserResult.cs ← NEW UpdateUserRequest.cs ← NEW Program.cs ← MODIFY (register interceptor + HttpContextAccessor) API/ROLAC.API.Tests/ Data/ AuditInterceptorTests.cs ← NEW ``` --- ## Task 1: Base Entity Classes **Files:** - Create: `API/ROLAC.API/Entities/Base/AuditableEntity.cs` - Create: `API/ROLAC.API/Entities/Base/SoftDeleteEntity.cs` - [ ] **Step 1: Create `AuditableEntity.cs`** ```csharp namespace ROLAC.API.Entities.Base; public abstract class AuditableEntity { public DateTime CreatedAt { get; set; } public string CreatedBy { get; set; } = null!; // FK → AspNetUsers.Id public DateTime UpdatedAt { get; set; } public string UpdatedBy { get; set; } = null!; // FK → AspNetUsers.Id } ``` - [ ] **Step 2: Create `SoftDeleteEntity.cs`** ```csharp namespace ROLAC.API.Entities.Base; public abstract class SoftDeleteEntity : AuditableEntity { public bool IsDeleted { get; set; } = false; public DateTime? DeletedAt { get; set; } public string? DeletedBy { get; set; } // FK → AspNetUsers.Id } ``` - [ ] **Step 3: Build to confirm no errors** ``` cd API/ROLAC.API dotnet build ``` Expected: Build succeeded, 0 errors. - [ ] **Step 4: Commit** ```bash git add API/ROLAC.API/Entities/Base/ git commit -m "feat: add AuditableEntity and SoftDeleteEntity base classes" ``` --- ## Task 2: AuditSaveChangesInterceptor **Files:** - Create: `API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs` - Create: `API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs` - [ ] **Step 1: Create the interceptor** ```csharp using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using ROLAC.API.Entities.Base; namespace ROLAC.API.Data.Interceptors; public class AuditSaveChangesInterceptor : SaveChangesInterceptor { private readonly IHttpContextAccessor _http; public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; public override InterceptionResult SavingChanges( DbContextEventData eventData, InterceptionResult result) { Stamp(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Stamp(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } private void Stamp(DbContext? db) { if (db is null) return; var userId = _http.HttpContext?.User .FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; var now = DateTime.UtcNow; foreach (var entry in db.ChangeTracker.Entries()) { if (entry.Entity is not AuditableEntity audit) continue; if (entry.State == EntityState.Added) { audit.CreatedAt = now; audit.CreatedBy = userId; audit.UpdatedAt = now; audit.UpdatedBy = userId; } else if (entry.State == EntityState.Modified) { audit.UpdatedAt = now; audit.UpdatedBy = userId; } } } } ``` - [ ] **Step 2: Write failing test** ```csharp // API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Moq; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.Entities; using Xunit; namespace ROLAC.API.Tests.Data; public class AuditInterceptorTests { private static AppDbContext BuildDb(AuditSaveChangesInterceptor interceptor) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(interceptor) .Options; return new AppDbContext(options); } private static AuditSaveChangesInterceptor BuildInterceptor(string userId = "user-1") { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; var mock = new Mock(); mock.Setup(x => x.HttpContext).Returns(ctx); return new AuditSaveChangesInterceptor(mock.Object); } [Fact] public async Task Added_SetsCreatedAtAndCreatedBy() { var interceptor = BuildInterceptor("user-42"); using var db = BuildDb(interceptor); var member = new Member { FirstName_en = "A", LastName_en = "B" }; db.Members.Add(member); await db.SaveChangesAsync(); Assert.Equal("user-42", member.CreatedBy); Assert.Equal("user-42", member.UpdatedBy); Assert.True(member.CreatedAt > DateTime.UtcNow.AddSeconds(-5)); } [Fact] public async Task Modified_UpdatesUpdatedAtAndUpdatedBy() { var interceptor = BuildInterceptor("user-1"); using var db = BuildDb(interceptor); var member = new Member { FirstName_en = "A", LastName_en = "B" }; db.Members.Add(member); await db.SaveChangesAsync(); member.NickName = "Nick"; await db.SaveChangesAsync(); Assert.Equal("user-1", member.UpdatedBy); } } ``` - [ ] **Step 3: Run test — expect FAIL** ``` cd API/ROLAC.API.Tests dotnet test --filter "AuditInterceptorTests" -v ``` Expected: Fails (Member entity not yet defined). - [ ] **Step 4: Build to confirm interceptor compiles** ``` cd API/ROLAC.API && dotnet build ``` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/Data/Interceptors/ API/ROLAC.API.Tests/Data/ git commit -m "feat: add AuditSaveChangesInterceptor" ``` --- ## Task 3: Member + FamilyUnit Entities **Files:** - Create: `API/ROLAC.API/Entities/Member.cs` - Create: `API/ROLAC.API/Entities/FamilyUnit.cs` - [ ] **Step 1: Create `Member.cs`** ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; public class Member : SoftDeleteEntity { public int Id { get; set; } public string FirstName_en { get; set; } = null!; public string LastName_en { get; set; } = null!; public string? NickName { get; set; } public string? FirstName_zh { get; set; } public string? LastName_zh { get; set; } public string? Gender { get; set; } // 'M' | 'F' | 'Other' public DateOnly? DateOfBirth { get; set; } public DateOnly? BaptismDate { get; set; } public string? BaptismChurch { get; set; } public string? Email { get; set; } public string? PhoneCell { get; set; } public string? PhoneHome { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? ZipCode { get; set; } public string Country { get; set; } = "USA"; public string? PhotoBlobPath { get; set; } public string Status { get; set; } = "Member"; // Member|Visitor|Inactive|Former public string LanguagePreference { get; set; } = "en"; public DateOnly? JoinDate { get; set; } public string? Notes { get; set; } public int? FamilyUnitId { get; set; } public FamilyUnit? FamilyUnit { get; set; } } ``` - [ ] **Step 2: Create `FamilyUnit.cs`** ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; public class FamilyUnit : AuditableEntity { public int Id { get; set; } public string? FamilyName_en { get; set; } public string? FamilyName_zh { get; set; } public string? Notes { get; set; } } ``` - [ ] **Step 3: Run the previously-failing interceptor test — expect PASS** ``` cd API/ROLAC.API.Tests dotnet test --filter "AuditInterceptorTests" -v ``` Expected: 2 tests pass. - [ ] **Step 4: Commit** ```bash git add API/ROLAC.API/Entities/Member.cs API/ROLAC.API/Entities/FamilyUnit.cs git commit -m "feat: add Member and FamilyUnit entities" ``` --- ## Task 4: AppDbContext Update + Program.cs + EF Migration **Files:** - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - Modify: `API/ROLAC.API/Program.cs` - Create: Migration (auto-generated) - [ ] **Step 1: Update `AppDbContext.cs`** — add DbSets and entity configuration Replace the full file: ```csharp using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ROLAC.API.Entities; namespace ROLAC.API.Data; public class AppDbContext : IdentityDbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet RefreshTokens => Set(); public DbSet Members => Set(); public DbSet FamilyUnits => Set(); protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); // ── RefreshToken (unchanged) ──────────────────────────────────────── builder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => e.TokenHash).IsUnique(); entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired(); entity.Property(e => e.UserId).HasMaxLength(450).IsRequired(); entity.Property(e => e.DeviceInfo).HasMaxLength(200); entity.Property(e => e.IpAddress).HasMaxLength(45); entity.Property(e => e.ReplacedByHash).HasMaxLength(64); entity.HasOne(e => e.User).WithMany(u => u.RefreshTokens) .HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade); entity.Ignore(e => e.IsExpired); entity.Ignore(e => e.IsRevoked); entity.Ignore(e => e.IsActive); }); // ── AppUser (unchanged + new unique index on MemberId) ────────────── builder.Entity(entity => { entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en"); // Nullable unique: one member ↔ one user account, but member can have no account entity.HasIndex(e => e.MemberId).IsUnique() .HasFilter("\"MemberId\" IS NOT NULL"); }); // ── AppRole (unchanged) ───────────────────────────────────────────── builder.Entity(entity => { entity.Property(e => e.Description).HasMaxLength(500); }); // ── FamilyUnit ────────────────────────────────────────────────────── builder.Entity(entity => { entity.Property(e => e.FamilyName_en).HasMaxLength(200); entity.Property(e => e.FamilyName_zh).HasMaxLength(200); }); // ── Member ────────────────────────────────────────────────────────── builder.Entity(entity => { entity.HasQueryFilter(m => !m.IsDeleted); entity.Property(e => e.FirstName_en).HasMaxLength(100).IsRequired(); entity.Property(e => e.LastName_en).HasMaxLength(100).IsRequired(); entity.Property(e => e.NickName).HasMaxLength(100); entity.Property(e => e.FirstName_zh).HasMaxLength(100); entity.Property(e => e.LastName_zh).HasMaxLength(100); entity.Property(e => e.Gender).HasMaxLength(10); entity.Property(e => e.BaptismChurch).HasMaxLength(200); entity.Property(e => e.Email).HasMaxLength(200); entity.Property(e => e.PhoneCell).HasMaxLength(30); entity.Property(e => e.PhoneHome).HasMaxLength(30); entity.Property(e => e.Address).HasMaxLength(500); entity.Property(e => e.City).HasMaxLength(100); entity.Property(e => e.State).HasMaxLength(50); entity.Property(e => e.ZipCode).HasMaxLength(20); entity.Property(e => e.Country).HasMaxLength(100).HasDefaultValue("USA"); entity.Property(e => e.PhotoBlobPath).HasMaxLength(500); entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Member"); entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en"); entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.DeletedBy).HasMaxLength(450); entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false"); entity.HasIndex(e => e.Email).HasFilter("\"Email\" IS NOT NULL"); entity.HasIndex(e => e.FamilyUnitId); entity.HasOne(e => e.FamilyUnit).WithMany() .HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull); }); } } ``` - [ ] **Step 2: Update `Program.cs`** — register interceptor and HttpContextAccessor Find and replace the `AddDbContext` block and add service registrations: ```csharp // At top of file, add using: using ROLAC.API.Data.Interceptors; // Replace existing AddDbContext: builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddDbContext((sp, opt) => opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) .AddInterceptors(sp.GetRequiredService())); ``` - [ ] **Step 3: Build to confirm no errors** ``` cd API/ROLAC.API && dotnet build ``` Expected: Build succeeded. - [ ] **Step 4: Create EF migration** ``` cd API/ROLAC.API dotnet ef migrations add AddMemberAndFamilyUnit ``` Expected: Migration file created at `Migrations/YYYYMMDD_AddMemberAndFamilyUnit.cs`. Verify the migration includes: `Members` table, `FamilyUnits` table, unique filtered index on `AspNetUsers.MemberId`. - [ ] **Step 5: Apply migration** ``` dotnet ef database update ``` Expected: `Done.` - [ ] **Step 6: Run all existing tests to confirm nothing broke** ``` cd API/ROLAC.API.Tests && dotnet test -v ``` Expected: All tests pass (including the 2 interceptor tests from Task 2). - [ ] **Step 7: Commit** ```bash git add API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Program.cs API/ROLAC.API/Migrations/ git commit -m "feat: add Member/FamilyUnit DbSets, audit interceptor registration, EF migration" ``` --- ## Task 5: DTOs **Files:** - Create: `API/ROLAC.API/DTOs/Shared/PagedResult.cs` - Create: `API/ROLAC.API/DTOs/Members/MemberListItemDto.cs` - Create: `API/ROLAC.API/DTOs/Members/MemberDto.cs` - Create: `API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs` - Create: `API/ROLAC.API/DTOs/Members/UpdateMemberRequest.cs` - Create: `API/ROLAC.API/DTOs/Users/UserListItemDto.cs` - Create: `API/ROLAC.API/DTOs/Users/UserDto.cs` - Create: `API/ROLAC.API/DTOs/Users/CreateUserRequest.cs` - Create: `API/ROLAC.API/DTOs/Users/CreateUserResult.cs` - Create: `API/ROLAC.API/DTOs/Users/UpdateUserRequest.cs` - [ ] **Step 1: Shared DTO** ```csharp // DTOs/Shared/PagedResult.cs namespace ROLAC.API.DTOs.Shared; public class PagedResult { public List Items { get; set; } = []; public int TotalCount { get; set; } public int Page { get; set; } public int PageSize { get; set; } public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); } ``` - [ ] **Step 2: Member DTOs** ```csharp // DTOs/Members/MemberListItemDto.cs namespace ROLAC.API.DTOs.Members; public class MemberListItemDto { public int Id { get; set; } public string FirstName_en { get; set; } = ""; public string LastName_en { get; set; } = ""; public string? NickName { get; set; } public string? FirstName_zh { get; set; } public string? LastName_zh { get; set; } public string Status { get; set; } = ""; public string? Email { get; set; } public string? PhoneCell { get; set; } public DateOnly? JoinDate { get; set; } public string? LinkedUserId { get; set; } // null = no user account } ``` ```csharp // DTOs/Members/MemberDto.cs namespace ROLAC.API.DTOs.Members; public class MemberDto : MemberListItemDto { public string? Gender { get; set; } public DateOnly? DateOfBirth { get; set; } public DateOnly? BaptismDate { get; set; } public string? BaptismChurch { get; set; } public string? PhoneHome { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? ZipCode { get; set; } public string Country { get; set; } = "USA"; public string? PhotoBlobPath { get; set; } public string LanguagePreference { get; set; } = "en"; public string? Notes { get; set; } public int? FamilyUnitId { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } ``` ```csharp // DTOs/Members/CreateMemberRequest.cs using System.ComponentModel.DataAnnotations; namespace ROLAC.API.DTOs.Members; public class CreateMemberRequest { [Required, MaxLength(100)] public string FirstName_en { get; set; } = ""; [Required, MaxLength(100)] public string LastName_en { get; set; } = ""; [MaxLength(100)] public string? NickName { get; set; } [MaxLength(100)] public string? FirstName_zh { get; set; } [MaxLength(100)] public string? LastName_zh { get; set; } [MaxLength(10)] public string? Gender { get; set; } public DateOnly? DateOfBirth { get; set; } public DateOnly? BaptismDate { get; set; } [MaxLength(200)] public string? BaptismChurch { get; set; } [MaxLength(200), EmailAddress] public string? Email { get; set; } [MaxLength(30)] public string? PhoneCell { get; set; } [MaxLength(30)] public string? PhoneHome { get; set; } [MaxLength(500)] public string? Address { get; set; } [MaxLength(100)] public string? City { get; set; } [MaxLength(50)] public string? State { get; set; } [MaxLength(20)] public string? ZipCode { get; set; } [MaxLength(100)] public string Country { get; set; } = "USA"; [MaxLength(20)] public string Status { get; set; } = "Member"; [MaxLength(10)] public string LanguagePreference { get; set; } = "en"; public DateOnly? JoinDate { get; set; } public string? Notes { get; set; } public int? FamilyUnitId { get; set; } } ``` ```csharp // DTOs/Members/UpdateMemberRequest.cs namespace ROLAC.API.DTOs.Members; public class UpdateMemberRequest : CreateMemberRequest { } ``` - [ ] **Step 3: User DTOs** ```csharp // DTOs/Users/UserListItemDto.cs namespace ROLAC.API.DTOs.Users; public class UserListItemDto { public string Id { get; set; } = ""; public string Email { get; set; } = ""; public int? MemberId { get; set; } public string? MemberDisplayName { get; set; } public List Roles { get; set; } = []; public bool IsActive { get; set; } public string LanguagePreference { get; set; } = "en"; public DateTime? LastLoginAt { get; set; } public DateTime CreatedAt { get; set; } } ``` ```csharp // DTOs/Users/UserDto.cs namespace ROLAC.API.DTOs.Users; public class UserDto : UserListItemDto { } ``` ```csharp // DTOs/Users/CreateUserRequest.cs using System.ComponentModel.DataAnnotations; namespace ROLAC.API.DTOs.Users; public class CreateUserRequest { [Required] public int MemberId { get; set; } [Required, EmailAddress] public string Email { get; set; } = ""; [Required, MinLength(1)] public List Roles { get; set; } = []; public string LanguagePreference { get; set; } = "en"; } ``` ```csharp // DTOs/Users/CreateUserResult.cs namespace ROLAC.API.DTOs.Users; public class CreateUserResult { public string UserId { get; set; } = ""; public string TempPassword { get; set; } = ""; } ``` ```csharp // DTOs/Users/UpdateUserRequest.cs using System.ComponentModel.DataAnnotations; namespace ROLAC.API.DTOs.Users; public class UpdateUserRequest { [Required, EmailAddress] public string Email { get; set; } = ""; [Required] public List Roles { get; set; } = []; public bool IsActive { get; set; } public string LanguagePreference { get; set; } = "en"; } ``` - [ ] **Step 4: Build** ``` cd API/ROLAC.API && dotnet build ``` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/DTOs/ git commit -m "feat: add PagedResult, Member DTOs, and User DTOs" ``` --- **Part 1 complete.** Continue with `2026-05-27-member-user-mgmt-part2-services-controllers.md`.