docs: add 3-part implementation plan for Member and User Management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-27 13:18:27 -07:00
parent 5d556b882d
commit 61c6697c87
3 changed files with 3481 additions and 0 deletions
@@ -0,0 +1,693 @@
# 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`**
```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<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**
```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<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**
```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<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:
```csharp
// 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**
```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<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**
```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<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; }
}
```
```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<string> 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<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**
```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`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff