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

694 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.