Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
23 KiB
Member & User Management — Part 1: Backend Infrastructure (Tasks 1–5)
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-developmentorsuperpowers:executing-plansto 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.