Files
ROLAC/docs/superpowers/plans/2026-05-27-member-user-mgmt-part2-services-controllers.md
T
2026-05-27 13:18:27 -07:00

1134 lines
42 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 2: Services & Controllers (Tasks 69)
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement task-by-task.
> **Prerequisite:** Part 1 complete (entities, migration, DTOs all in place).
**Goal:** Implement MemberService, UserManagementService, MembersController, and UsersController with full test coverage.
**Architecture:** Services inject `AppDbContext` + `IHttpContextAccessor`. Controllers are thin — validation, HTTP status mapping, and delegation only. Soft-delete is done explicitly in `DeleteAsync` (sets `IsDeleted = true`) so tests work without the interceptor.
**Tech Stack:** C# .NET 8, EF Core 8, ASP.NET Identity, xUnit, Moq, EF InMemory
**Spec:** `docs/superpowers/specs/2026-05-27-aspnetusers-member-management-design.md`
---
## File Structure
```
API/ROLAC.API/
Services/
IMemberService.cs ← NEW
MemberService.cs ← NEW
IUserManagementService.cs ← NEW
UserManagementService.cs ← NEW
Controllers/
MembersController.cs ← NEW
UsersController.cs ← NEW
Program.cs ← MODIFY (register new services)
API/ROLAC.API.Tests/
Services/
MemberServiceTests.cs ← NEW
UserManagementServiceTests.cs ← NEW
```
---
## Task 6: MemberService
**Files:**
- Create: `API/ROLAC.API/Services/IMemberService.cs`
- Create: `API/ROLAC.API/Services/MemberService.cs`
- Create: `API/ROLAC.API.Tests/Services/MemberServiceTests.cs`
- [ ] **Step 1: Write the failing tests first**
```csharp
// API/ROLAC.API.Tests/Services/MemberServiceTests.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MemberServiceTests
{
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
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 mock.Object;
}
// ── Create ───────────────────────────────────────────────────────────────
[Fact]
public async Task CreateAsync_ReturnsNewId()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var request = new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" };
var id = await svc.CreateAsync(request);
Assert.True(id > 0);
var saved = await db.Members.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal("Chris", saved.FirstName_en);
}
[Fact]
public async Task CreateAsync_SavesNickName()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var request = new CreateMemberRequest
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" };
var id = await svc.CreateAsync(request);
var saved = await db.Members.FindAsync(id);
Assert.Equal("Chris", saved!.NickName);
}
// ── GetById ──────────────────────────────────────────────────────────────
[Fact]
public async Task GetByIdAsync_ReturnsDto_WhenExists()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var id = await svc.CreateAsync(
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
Assert.Equal(id, dto.Id);
Assert.Equal("A", dto.FirstName_en);
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var dto = await svc.GetByIdAsync(9999);
Assert.Null(dto);
}
// ── GetPaged ─────────────────────────────────────────────────────────────
[Fact]
public async Task GetPagedAsync_FiltersOnSearch()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" });
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Alice", LastName_en = "Wang" });
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
Assert.Equal(1, result.TotalCount);
Assert.Equal("Chris", result.Items[0].FirstName_en);
}
[Fact]
public async Task GetPagedAsync_FiltersOnStatus()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await svc.CreateAsync(new CreateMemberRequest
{ FirstName_en = "A", LastName_en = "A", Status = "Member" });
await svc.CreateAsync(new CreateMemberRequest
{ FirstName_en = "B", LastName_en = "B", Status = "Visitor" });
var result = await svc.GetPagedAsync(1, 20, null, "Visitor", null);
Assert.Equal(1, result.TotalCount);
Assert.Equal("Visitor", result.Items[0].Status);
}
[Fact]
public async Task GetPagedAsync_SearchesNickName()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await svc.CreateAsync(new CreateMemberRequest
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" });
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
Assert.Equal(1, result.TotalCount);
}
// ── Update ───────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateAsync_PersistsChanges()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
var id = await svc.CreateAsync(
new CreateMemberRequest { FirstName_en = "Old", LastName_en = "Name" });
await svc.UpdateAsync(id, new UpdateMemberRequest
{ FirstName_en = "New", LastName_en = "Name", Country = "USA",
Status = "Member", LanguagePreference = "en" });
var saved = await db.Members.FindAsync(id);
Assert.Equal("New", saved!.FirstName_en);
}
[Fact]
public async Task UpdateAsync_ThrowsKeyNotFound_WhenMissing()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateAsync(9999, new UpdateMemberRequest
{ FirstName_en = "X", LastName_en = "Y", Country = "USA",
Status = "Member", LanguagePreference = "en" }));
}
// ── Delete (soft) ────────────────────────────────────────────────────────
[Fact]
public async Task DeleteAsync_SoftDeletesMember()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor("deleter-id"));
var id = await svc.CreateAsync(
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
await svc.DeleteAsync(id);
// Query-filtered view returns null
var filtered = await db.Members.FindAsync(id);
Assert.Null(filtered);
// Raw view shows IsDeleted = true
var raw = await db.Members.IgnoreQueryFilters()
.FirstAsync(m => m.Id == id);
Assert.True(raw.IsDeleted);
Assert.Equal("deleter-id", raw.DeletedBy);
Assert.NotNull(raw.DeletedAt);
}
[Fact]
public async Task DeleteAsync_ThrowsKeyNotFound_WhenMissing()
{
using var db = BuildDb();
var svc = new MemberService(db, BuildAccessor());
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(9999));
}
}
```
- [ ] **Step 2: Run tests — expect FAIL (IMemberService doesn't exist)**
```
cd API/ROLAC.API.Tests && dotnet test --filter "MemberServiceTests" -v
```
Expected: Build error — `MemberService` not found.
- [ ] **Step 3: Create `IMemberService.cs`**
```csharp
using ROLAC.API.DTOs.Members;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IMemberService
{
Task<PagedResult<MemberListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, string? status, bool? hasUser);
Task<MemberDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateMemberRequest request);
Task UpdateAsync(int id, UpdateMemberRequest request);
Task DeleteAsync(int id);
}
```
- [ ] **Step 4: Create `MemberService.cs`**
```csharp
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Members;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class MemberService : IMemberService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
public MemberService(AppDbContext db, IHttpContextAccessor http)
{
_db = db;
_http = http;
}
private string CurrentUserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
// ── GetPaged ─────────────────────────────────────────────────────────────
public async Task<PagedResult<MemberListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, string? status, bool? hasUser)
{
var query = from m in _db.Members
join u in _db.Users on (int?)m.Id equals u.MemberId into ug
from u in ug.DefaultIfEmpty()
select new { m, LinkedUserId = u != null ? u.Id : null };
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(x =>
x.m.FirstName_en.ToLower().Contains(s) ||
x.m.LastName_en.ToLower().Contains(s) ||
(x.m.NickName != null && x.m.NickName.ToLower().Contains(s)) ||
(x.m.FirstName_zh != null && x.m.FirstName_zh.Contains(search)) ||
(x.m.LastName_zh != null && x.m.LastName_zh.Contains(search)) ||
(x.m.Email != null && x.m.Email.ToLower().Contains(s)));
}
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.m.Status == status);
if (hasUser.HasValue)
query = hasUser.Value
? query.Where(x => x.LinkedUserId != null)
: query.Where(x => x.LinkedUserId == null);
var total = await query.CountAsync();
var items = await query
.OrderBy(x => x.m.LastName_en).ThenBy(x => x.m.FirstName_en)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new MemberListItemDto
{
Id = x.m.Id,
FirstName_en = x.m.FirstName_en,
LastName_en = x.m.LastName_en,
NickName = x.m.NickName,
FirstName_zh = x.m.FirstName_zh,
LastName_zh = x.m.LastName_zh,
Status = x.m.Status,
Email = x.m.Email,
PhoneCell = x.m.PhoneCell,
JoinDate = x.m.JoinDate,
LinkedUserId = x.LinkedUserId,
})
.ToListAsync();
return new PagedResult<MemberListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
// ── GetById ──────────────────────────────────────────────────────────────
public async Task<MemberDto?> GetByIdAsync(int id)
{
var row = await (from m in _db.Members
join u in _db.Users on (int?)m.Id equals u.MemberId into ug
from u in ug.DefaultIfEmpty()
where m.Id == id
select new { m, LinkedUserId = u != null ? u.Id : null })
.AsNoTracking()
.FirstOrDefaultAsync();
if (row is null) return null;
var m = row.m;
return new MemberDto
{
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
ZipCode = m.ZipCode, Country = m.Country, PhotoBlobPath = m.PhotoBlobPath,
Status = m.Status, LanguagePreference = m.LanguagePreference, JoinDate = m.JoinDate,
Notes = m.Notes, FamilyUnitId = m.FamilyUnitId,
LinkedUserId = row.LinkedUserId,
CreatedAt = m.CreatedAt, UpdatedAt = m.UpdatedAt,
};
}
// ── Create ───────────────────────────────────────────────────────────────
public async Task<int> CreateAsync(CreateMemberRequest r)
{
var member = MapFromRequest(r);
_db.Members.Add(member);
await _db.SaveChangesAsync();
return member.Id;
}
// ── Update ───────────────────────────────────────────────────────────────
public async Task UpdateAsync(int id, UpdateMemberRequest r)
{
var m = await _db.Members.FindAsync(id)
?? throw new KeyNotFoundException($"Member {id} not found.");
ApplyRequest(m, r);
await _db.SaveChangesAsync();
}
// ── Delete (soft) ────────────────────────────────────────────────────────
public async Task DeleteAsync(int id)
{
var m = await _db.Members.FindAsync(id)
?? throw new KeyNotFoundException($"Member {id} not found.");
m.IsDeleted = true;
m.DeletedAt = DateTime.UtcNow;
m.DeletedBy = CurrentUserId;
await _db.SaveChangesAsync();
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static Member MapFromRequest(CreateMemberRequest r) => new()
{
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
ZipCode = r.ZipCode, Country = r.Country, Status = r.Status,
LanguagePreference = r.LanguagePreference, JoinDate = r.JoinDate,
Notes = r.Notes, FamilyUnitId = r.FamilyUnitId,
};
private static void ApplyRequest(Member m, CreateMemberRequest r)
{
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
m.ZipCode = r.ZipCode; m.Country = r.Country; m.Status = r.Status;
m.LanguagePreference = r.LanguagePreference; m.JoinDate = r.JoinDate;
m.Notes = r.Notes; m.FamilyUnitId = r.FamilyUnitId;
}
}
```
- [ ] **Step 5: Run tests — expect PASS**
```
cd API/ROLAC.API.Tests && dotnet test --filter "MemberServiceTests" -v
```
Expected: All 9 tests pass.
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/Services/IMemberService.cs API/ROLAC.API/Services/MemberService.cs \
API/ROLAC.API.Tests/Services/MemberServiceTests.cs
git commit -m "feat: add MemberService with soft-delete and paged search"
```
---
## Task 7: MembersController
**Files:**
- Create: `API/ROLAC.API/Controllers/MembersController.cs`
- Modify: `API/ROLAC.API/Program.cs`
- [ ] **Step 1: Create `MembersController.cs`**
```csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/members")]
[Authorize]
public class MembersController : ControllerBase
{
private readonly IMemberService _members;
public MembersController(IMemberService members) => _members = members;
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
[HttpGet]
[Authorize(Roles = "super_admin,secretary,pastor")]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] string? status = null,
[FromQuery] bool? hasUser = null)
=> Ok(await _members.GetPagedAsync(page, pageSize, search, status, hasUser));
/// <summary>GET /api/members/{id}</summary>
[HttpGet("{id:int}")]
[Authorize(Roles = "super_admin,secretary,pastor")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _members.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>POST /api/members</summary>
[HttpPost]
[Authorize(Roles = "super_admin,secretary")]
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
{
var id = await _members.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
/// <summary>PUT /api/members/{id}</summary>
[HttpPut("{id:int}")]
[Authorize(Roles = "super_admin,secretary")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
{
try { await _members.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
/// <summary>DELETE /api/members/{id} — soft delete</summary>
[HttpDelete("{id:int}")]
[Authorize(Roles = "super_admin,secretary")]
public async Task<IActionResult> Delete(int id)
{
try { await _members.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
```
- [ ] **Step 2: Register `IMemberService` in `Program.cs`**
Add after the existing service registrations (before `builder.Services.AddControllers()`):
```csharp
builder.Services.AddScoped<IMemberService, MemberService>();
```
- [ ] **Step 3: Build and verify Swagger shows the new endpoints**
```
cd API/ROLAC.API && dotnet run
```
Open `https://localhost:{port}/swagger` — verify 5 `/api/members` endpoints appear.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/Controllers/MembersController.cs API/ROLAC.API/Program.cs
git commit -m "feat: add MembersController (CRUD + paged list)"
```
---
## Task 8: UserManagementService
**Files:**
- Create: `API/ROLAC.API/Services/IUserManagementService.cs`
- Create: `API/ROLAC.API/Services/UserManagementService.cs`
- Create: `API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
```csharp
// API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class UserManagementServiceTests
{
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null,
bool createOk = true,
IList<string>? roles = null)
{
var store = new Mock<IUserStore<AppUser>>();
#pragma warning disable CS8625
var mgr = new Mock<UserManager<AppUser>>(
store.Object, null, null, null, null, null, null, null, null);
#pragma warning restore CS8625
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
.ReturnsAsync(findResult);
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((AppUser?)null);
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
.ReturnsAsync(createOk ? IdentityResult.Success
: IdentityResult.Failed(new IdentityError { Description = "fail" }));
mgr.Setup(m => m.AddToRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
.ReturnsAsync(roles ?? new List<string> { "member" });
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny<AppUser>()))
.ReturnsAsync("reset-token");
mgr.Setup(m => m.ResetPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(IdentityResult.Success);
return mgr;
}
// ── CreateAsync ──────────────────────────────────────────────────────────
[Fact]
public async Task CreateAsync_ReturnsTempPassword()
{
using var db = BuildDb();
// Seed a Member so MemberId validation passes
var member = new Member { FirstName_en = "A", LastName_en = "B" };
db.Members.Add(member);
await db.SaveChangesAsync();
var mgr = BuildUserManager();
// Capture the AppUser passed to CreateAsync
AppUser? created = null;
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
.Callback<AppUser, string>((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); })
.ReturnsAsync(IdentityResult.Success);
var svc = new UserManagementService(mgr.Object, db);
var result = await svc.CreateAsync(new CreateUserRequest
{
MemberId = member.Id,
Email = "test@rolac.org",
Roles = ["member"],
});
Assert.False(string.IsNullOrEmpty(result.TempPassword));
Assert.Equal(12, result.TempPassword.Length);
Assert.NotNull(created);
Assert.Equal(member.Id, created!.MemberId);
}
[Fact]
public async Task CreateAsync_Throws_WhenMemberNotFound()
{
using var db = BuildDb();
var mgr = BuildUserManager();
var svc = new UserManagementService(mgr.Object, db);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest
{ MemberId = 9999, Email = "x@y.com", Roles = ["member"] }));
}
[Fact]
public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser()
{
using var db = BuildDb();
var member = new Member { FirstName_en = "A", LastName_en = "B" };
db.Members.Add(member);
// Seed an AppUser with MemberId set in the Users table
var existingUser = new AppUser
{
Id = Guid.NewGuid().ToString(),
UserName = "existing@test.com",
Email = "existing@test.com",
MemberId = null, // will be set below
};
// Use IgnoreQueryFilters-safe approach: directly set via db
db.SaveChanges();
member = db.Members.First();
existingUser.MemberId = member.Id;
db.Users.Add(existingUser);
await db.SaveChangesAsync();
var mgr = BuildUserManager();
var svc = new UserManagementService(mgr.Object, db);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest
{ MemberId = member.Id, Email = "new@test.com", Roles = ["member"] }));
}
// ── DeactivateAsync ──────────────────────────────────────────────────────
[Fact]
public async Task DeactivateAsync_SetsIsActiveFalse()
{
using var db = BuildDb();
var user = new AppUser
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db);
await svc.DeactivateAsync("u1");
Assert.False(user.IsActive);
Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd);
}
[Fact]
public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing()
{
using var db = BuildDb();
var mgr = BuildUserManager(findResult: null);
var svc = new UserManagementService(mgr.Object, db);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
}
// ── ResetPasswordAsync ───────────────────────────────────────────────────
[Fact]
public async Task ResetPasswordAsync_ReturnsNewTempPassword()
{
using var db = BuildDb();
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db);
var pwd = await svc.ResetPasswordAsync("u1");
Assert.Equal(12, pwd.Length);
}
}
```
- [ ] **Step 2: Run tests — expect FAIL**
```
cd API/ROLAC.API.Tests && dotnet test --filter "UserManagementServiceTests" -v
```
Expected: Build error — `UserManagementService` not found.
- [ ] **Step 3: Create `IUserManagementService.cs`**
```csharp
using ROLAC.API.DTOs.Shared;
using ROLAC.API.DTOs.Users;
namespace ROLAC.API.Services;
public interface IUserManagementService
{
Task<PagedResult<UserListItemDto>> GetPagedAsync(int page, int pageSize, string? search);
Task<UserDto?> GetByIdAsync(string id);
Task<CreateUserResult> CreateAsync(CreateUserRequest request);
Task UpdateAsync(string id, UpdateUserRequest request);
Task DeactivateAsync(string id);
Task<string> ResetPasswordAsync(string id);
}
```
- [ ] **Step 4: Create `UserManagementService.cs`**
```csharp
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class UserManagementService : IUserManagementService
{
private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db;
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db)
{
_userManager = userManager;
_db = db;
}
// ── GetPaged ─────────────────────────────────────────────────────────────
public async Task<PagedResult<UserListItemDto>> GetPagedAsync(
int page, int pageSize, string? search)
{
var query = from u in _userManager.Users
join m in _db.Members.IgnoreQueryFilters()
on u.MemberId equals (int?)m.Id into mg
from m in mg.DefaultIfEmpty()
select new { u, m };
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(x =>
x.u.Email!.ToLower().Contains(s) ||
(x.m != null && (
x.m.FirstName_en.ToLower().Contains(s) ||
x.m.LastName_en.ToLower().Contains(s) ||
(x.m.NickName != null && x.m.NickName.ToLower().Contains(s)))));
}
var total = await query.CountAsync();
var rows = await query
.OrderBy(x => x.u.Email)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.u.Id, x.u.Email, x.u.MemberId, x.u.IsActive,
x.u.LanguagePreference, x.u.LastLoginAt, x.u.CreatedAt,
MemberDisplayName = x.m != null
? (x.m.NickName ?? x.m.FirstName_en) + " " + x.m.LastName_en
: (string?)null,
})
.ToListAsync();
// Batch-load roles
var userIds = rows.Select(r => r.Id).ToList();
var roleMap = await (
from ur in _db.UserRoles
join r in _db.Roles on ur.RoleId equals r.Id
where userIds.Contains(ur.UserId)
select new { ur.UserId, r.Name }
).ToListAsync();
var rolesByUser = roleMap
.GroupBy(x => x.UserId)
.ToDictionary(g => g.Key, g => g.Select(x => x.Name!).ToList());
var items = rows.Select(r => new UserListItemDto
{
Id = r.Id,
Email = r.Email ?? "",
MemberId = r.MemberId,
MemberDisplayName = r.MemberDisplayName,
IsActive = r.IsActive,
LanguagePreference = r.LanguagePreference,
LastLoginAt = r.LastLoginAt,
CreatedAt = r.CreatedAt,
Roles = rolesByUser.GetValueOrDefault(r.Id, []),
}).ToList();
return new PagedResult<UserListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
// ── GetById ──────────────────────────────────────────────────────────────
public async Task<UserDto?> GetByIdAsync(string id)
{
var user = await _userManager.FindByIdAsync(id);
if (user is null) return null;
var roles = await _userManager.GetRolesAsync(user);
var memberName = user.MemberId.HasValue
? await _db.Members.IgnoreQueryFilters()
.Where(m => m.Id == user.MemberId)
.Select(m => (m.NickName ?? m.FirstName_en) + " " + m.LastName_en)
.FirstOrDefaultAsync()
: null;
return new UserDto
{
Id = user.Id,
Email = user.Email ?? "",
MemberId = user.MemberId,
MemberDisplayName = memberName,
Roles = roles.ToList(),
IsActive = user.IsActive,
LanguagePreference = user.LanguagePreference,
LastLoginAt = user.LastLoginAt,
CreatedAt = user.CreatedAt,
};
}
// ── Create ───────────────────────────────────────────────────────────────
public async Task<CreateUserResult> CreateAsync(CreateUserRequest request)
{
// Validate Member exists
var member = await _db.Members.FindAsync(request.MemberId)
?? throw new InvalidOperationException(
$"Member {request.MemberId} does not exist.");
// One user per member
if (await _userManager.Users.AnyAsync(u => u.MemberId == request.MemberId))
throw new InvalidOperationException(
"This member already has a user account.");
// Unique email
if (await _userManager.FindByEmailAsync(request.Email) is not null)
throw new InvalidOperationException(
$"Email '{request.Email}' is already in use.");
var tempPassword = GenerateTempPassword();
var user = new AppUser
{
UserName = request.Email,
Email = request.Email,
EmailConfirmed = true,
MemberId = request.MemberId,
LanguagePreference = request.LanguagePreference,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
var result = await _userManager.CreateAsync(user, tempPassword);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(e => e.Description)));
await _userManager.AddToRolesAsync(user, request.Roles);
return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword };
}
// ── Update ───────────────────────────────────────────────────────────────
public async Task UpdateAsync(string id, UpdateUserRequest request)
{
var user = await _userManager.FindByIdAsync(id)
?? throw new KeyNotFoundException($"User {id} not found.");
user.Email = request.Email;
user.UserName = request.Email;
user.NormalizedEmail = request.Email.ToUpperInvariant();
user.NormalizedUserName = request.Email.ToUpperInvariant();
user.LanguagePreference = request.LanguagePreference;
user.IsActive = request.IsActive;
user.LockoutEnd = request.IsActive ? null : DateTimeOffset.MaxValue;
var updateResult = await _userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
throw new InvalidOperationException(
string.Join("; ", updateResult.Errors.Select(e => e.Description)));
var currentRoles = await _userManager.GetRolesAsync(user);
var toRemove = currentRoles.Except(request.Roles).ToList();
var toAdd = request.Roles.Except(currentRoles).ToList();
if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove);
if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd);
}
// ── Deactivate ───────────────────────────────────────────────────────────
public async Task DeactivateAsync(string id)
{
var user = await _userManager.FindByIdAsync(id)
?? throw new KeyNotFoundException($"User {id} not found.");
user.IsActive = false;
user.LockoutEnd = DateTimeOffset.MaxValue;
await _userManager.UpdateAsync(user);
}
// ── ResetPassword ────────────────────────────────────────────────────────
public async Task<string> ResetPasswordAsync(string id)
{
var user = await _userManager.FindByIdAsync(id)
?? throw new KeyNotFoundException($"User {id} not found.");
var tempPassword = GenerateTempPassword();
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, token, tempPassword);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(e => e.Description)));
return tempPassword;
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static string GenerateTempPassword()
{
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const string lower = "abcdefghjkmnpqrstuvwxyz";
const string digits = "23456789";
const string special = "!@#$%^";
const string all = upper + lower + digits + special;
var rng = new Random();
var pw = new char[12];
pw[0] = upper[rng.Next(upper.Length)];
pw[1] = lower[rng.Next(lower.Length)];
pw[2] = digits[rng.Next(digits.Length)];
pw[3] = special[rng.Next(special.Length)];
for (var i = 4; i < 12; i++) pw[i] = all[rng.Next(all.Length)];
return new string(pw.OrderBy(_ => rng.Next()).ToArray());
}
}
```
- [ ] **Step 5: Run tests — expect PASS**
```
cd API/ROLAC.API.Tests && dotnet test --filter "UserManagementServiceTests" -v
```
Expected: All 6 tests pass.
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/Services/IUserManagementService.cs \
API/ROLAC.API/Services/UserManagementService.cs \
API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs
git commit -m "feat: add UserManagementService with temp-password creation and deactivation"
```
---
## Task 9: UsersController + Program.cs Registration
**Files:**
- Create: `API/ROLAC.API/Controllers/UsersController.cs`
- Modify: `API/ROLAC.API/Program.cs`
- [ ] **Step 1: Create `UsersController.cs`**
```csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/users")]
[Authorize(Roles = "super_admin")]
public class UsersController : ControllerBase
{
private readonly IUserManagementService _users;
public UsersController(IUserManagementService users) => _users = users;
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null)
=> Ok(await _users.GetPagedAsync(page, pageSize, search));
/// <summary>GET /api/users/{id}</summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(string id)
{
var dto = await _users.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>
/// POST /api/users — creates account for a Member, returns { userId, tempPassword }.
/// TempPassword is returned ONCE — show it to the admin and never log it.
/// </summary>
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
try
{
var result = await _users.CreateAsync(request);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
{
try { await _users.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Deactivate(string id)
{
try { await _users.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
[HttpPost("{id}/reset-password")]
public async Task<IActionResult> ResetPassword(string id)
{
try
{
var pwd = await _users.ResetPasswordAsync(id);
return Ok(new { tempPassword = pwd });
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
```
- [ ] **Step 2: Register `IUserManagementService` in `Program.cs`**
Add alongside the `IMemberService` registration:
```csharp
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
```
- [ ] **Step 3: Build and smoke-test via Swagger**
```
cd API/ROLAC.API && dotnet run
```
Open Swagger → authenticate as `admin@rolac.org / Admin1234!` → call `GET /api/members` → expect `200 { items: [], totalCount: 0 }`.
- [ ] **Step 4: Run all tests**
```
cd API/ROLAC.API.Tests && dotnet test -v
```
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add API/ROLAC.API/Controllers/UsersController.cs API/ROLAC.API/Program.cs
git commit -m "feat: add UsersController and register all services"
```
---
**Part 2 complete.** Continue with `2026-05-27-member-user-mgmt-part3-frontend.md`.