Files
ROLAC/docs/superpowers/plans/2026-05-27-member-user-mgmt-part1-backend-infra.md
2026-05-27 13:18:27 -07:00

23 KiB
Raw Permalink Blame History

Member & User Management — Part 1: Backend Infrastructure (Tasks 15)

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

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
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
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

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<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        Stamp(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> 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
// 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<AppDbContext>()
            .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<IHttpContextAccessor>();
        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
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

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
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
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:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities;

namespace ROLAC.API.Data;

public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
    public DbSet<Member>       Members       => Set<Member>();
    public DbSet<FamilyUnit>   FamilyUnits   => Set<FamilyUnit>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        // ── RefreshToken (unchanged) ────────────────────────────────────────
        builder.Entity<RefreshToken>(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<AppUser>(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<AppRole>(entity =>
        {
            entity.Property(e => e.Description).HasMaxLength(500);
        });

        // ── FamilyUnit ──────────────────────────────────────────────────────
        builder.Entity<FamilyUnit>(entity =>
        {
            entity.Property(e => e.FamilyName_en).HasMaxLength(200);
            entity.Property(e => e.FamilyName_zh).HasMaxLength(200);
        });

        // ── Member ──────────────────────────────────────────────────────────
        builder.Entity<Member>(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:

// At top of file, add using:
using ROLAC.API.Data.Interceptors;

// Replace existing AddDbContext:
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
    opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
       .AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
  • 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
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

// DTOs/Shared/PagedResult.cs
namespace ROLAC.API.DTOs.Shared;

public class PagedResult<T>
{
    public List<T> 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
// 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
}
// 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; }
}
// 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; }
}
// DTOs/Members/UpdateMemberRequest.cs
namespace ROLAC.API.DTOs.Members;
public class UpdateMemberRequest : CreateMemberRequest { }
  • Step 3: User DTOs
// 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<string> Roles             { get; set; } = [];
    public bool         IsActive          { get; set; }
    public string       LanguagePreference { get; set; } = "en";
    public DateTime?    LastLoginAt       { get; set; }
    public DateTime     CreatedAt         { get; set; }
}
// DTOs/Users/UserDto.cs
namespace ROLAC.API.DTOs.Users;
public class UserDto : UserListItemDto { }
// 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<string> Roles             { get; set; } = [];
                                public string      LanguagePreference { get; set; } = "en";
}
// DTOs/Users/CreateUserResult.cs
namespace ROLAC.API.DTOs.Users;

public class CreateUserResult
{
    public string UserId       { get; set; } = "";
    public string TempPassword { get; set; } = "";
}
// 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<string> 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
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.