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

42 KiB
Raw Blame History

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

// 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 IMemberService in Program.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 IUserManagementService in Program.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.