61c6697c87
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
694 lines
23 KiB
Markdown
694 lines
23 KiB
Markdown
# 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<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`.
|