Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
42 KiB
Member & User Management — Part 2: Services & Controllers (Tasks 6–9)
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-developmentorsuperpowers:executing-plansto 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
// 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
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
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
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
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
IMemberServiceinProgram.cs
Add after the existing service registrations (before builder.Services.AddControllers()):
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
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
// 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
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
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
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
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
IUserManagementServiceinProgram.cs
Add alongside the IMemberService registration:
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
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.