# Member & User Management — Part 2: Services & Controllers (Tasks 6–9) > **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() .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(); 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(() => 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(() => 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> GetPagedAsync( int page, int pageSize, string? search, string? status, bool? hasUser); Task GetByIdAsync(int id); Task 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> 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 { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; } // ── GetById ────────────────────────────────────────────────────────────── public async Task 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 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; /// GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false [HttpGet] [Authorize(Roles = "super_admin,secretary,pastor")] public async Task 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)); /// GET /api/members/{id} [HttpGet("{id:int}")] [Authorize(Roles = "super_admin,secretary,pastor")] public async Task GetById(int id) { var dto = await _members.GetByIdAsync(id); return dto is null ? NotFound() : Ok(dto); } /// POST /api/members [HttpPost] [Authorize(Roles = "super_admin,secretary")] public async Task Create([FromBody] CreateMemberRequest request) { var id = await _members.CreateAsync(request); return CreatedAtAction(nameof(GetById), new { id }, new { id }); } /// PUT /api/members/{id} [HttpPut("{id:int}")] [Authorize(Roles = "super_admin,secretary")] public async Task Update(int id, [FromBody] UpdateMemberRequest request) { try { await _members.UpdateAsync(id, request); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } /// DELETE /api/members/{id} — soft delete [HttpDelete("{id:int}")] [Authorize(Roles = "super_admin,secretary")] public async Task 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(); ``` - [ ] **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() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static Mock> BuildUserManager( AppUser? findResult = null, bool createOk = true, IList? roles = null) { var store = new Mock>(); #pragma warning disable CS8625 var mgr = new Mock>( store.Object, null, null, null, null, null, null, null, null); #pragma warning restore CS8625 mgr.Setup(m => m.FindByIdAsync(It.IsAny())) .ReturnsAsync(findResult); mgr.Setup(m => m.FindByEmailAsync(It.IsAny())) .ReturnsAsync((AppUser?)null); mgr.Setup(m => m.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(createOk ? IdentityResult.Success : IdentityResult.Failed(new IdentityError { Description = "fail" })); mgr.Setup(m => m.AddToRolesAsync(It.IsAny(), It.IsAny>())) .ReturnsAsync(IdentityResult.Success); mgr.Setup(m => m.GetRolesAsync(It.IsAny())) .ReturnsAsync(roles ?? new List { "member" }); mgr.Setup(m => m.UpdateAsync(It.IsAny())) .ReturnsAsync(IdentityResult.Success); mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny(), It.IsAny>())) .ReturnsAsync(IdentityResult.Success); mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny())) .ReturnsAsync("reset-token"); mgr.Setup(m => m.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny())) .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(), It.IsAny())) .Callback((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(() => 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(() => 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(() => 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> GetPagedAsync(int page, int pageSize, string? search); Task GetByIdAsync(string id); Task CreateAsync(CreateUserRequest request); Task UpdateAsync(string id, UpdateUserRequest request); Task DeactivateAsync(string id); Task 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 _userManager; private readonly AppDbContext _db; public UserManagementService(UserManager userManager, AppDbContext db) { _userManager = userManager; _db = db; } // ── GetPaged ───────────────────────────────────────────────────────────── public async Task> 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 { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; } // ── GetById ────────────────────────────────────────────────────────────── public async Task 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 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 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; /// GET /api/users?page=1&pageSize=20&search=Chris [HttpGet] public async Task GetPaged( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null) => Ok(await _users.GetPagedAsync(page, pageSize, search)); /// GET /api/users/{id} [HttpGet("{id}")] public async Task GetById(string id) { var dto = await _users.GetByIdAsync(id); return dto is null ? NotFound() : Ok(dto); } /// /// POST /api/users — creates account for a Member, returns { userId, tempPassword }. /// TempPassword is returned ONCE — show it to the admin and never log it. /// [HttpPost] public async Task Create([FromBody] CreateUserRequest request) { try { var result = await _users.CreateAsync(request); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); } } /// PUT /api/users/{id} — update email, roles, IsActive [HttpPut("{id}")] public async Task 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 }); } } /// DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete [HttpDelete("{id}")] public async Task Deactivate(string id) { try { await _users.DeactivateAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } /// POST /api/users/{id}/reset-password — returns new temp password [HttpPost("{id}/reset-password")] public async Task 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(); ``` - [ ] **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`.