Files
mws.backend.dotnet/docs/superpowers/plans/2026-08-13-cqrs-mediator.md
T
namdh f72aaa2329 Commit MWS backend source tree
The ASP.NET Core solution (mws.api/mws.application/mws.domain/
mws.infrastructure) existed only as untracked working files. Adding
it to version control, plus a .gitignore for build output and local
tooling directories, so the CQRS/mediator refactor plan has a real
git history to branch and diff against.
2026-08-13 23:10:22 +07:00

83 KiB

CQRS/Mediator Refactor Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the 9 IXService/XService application-layer classes with MediatR commands/queries + handlers, one per operation, so controllers depend only on ISender.

Architecture: Each old service method becomes a record XCommand/XQuery : IRequest<TResponse> plus an XHandler : IRequestHandler<...> colocated in one file under a Commands/ or Queries/ folder per module. Handler bodies are the old method bodies moved verbatim. IPermissionService, ITokenService, IPasswordHasher stay plain injected services (not mediator requests) since they're cross-cutting helpers called from inside other handlers/controllers, not endpoint operations themselves.

Tech Stack: .NET 10, ASP.NET Core, EF Core/Npgsql, AutoMapper (unchanged), MediatR (new).

Spec: docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md

Global Constraints

  • No behavior change: every handler's logic must match the old service method's logic exactly (same exceptions, same order of checks, same repository calls).
  • No test project exists in this repo — verification is dotnet build mws.backend.dotnet.sln (must show 0 Error(s)) plus a manual curl smoke test in the final task.
  • Solution file is mws.backend.dotnet.sln at repo root; actual project folders are lowercase (mws.api/, mws.application/, mws.domain/, mws.infrastructure/) despite CLAUDE.md referring to PascalCase paths — use the lowercase paths, they are what's on disk.
  • IPermissionService, ITokenService, IPasswordHasher registrations and implementations are NOT touched by this plan.
  • AutoMapper (IMapper) usage is unchanged — same mapper.Map<T>(...) calls, just moved into handlers.
  • Commit after each task (one module = one commit), so the branch is bisectable if something regresses.

Task 1: Add MediatR, wire DI, convert Auth module

Files:

  • Modify: mws.application/mws.application.csproj (add MediatR package)
  • Modify: mws.infrastructure/DependencyInjection.cs (add AddMediatR, remove AddScoped<IAuthService, AuthService>)
  • Create: mws.application/Auth/Commands/Login.cs
  • Delete: mws.application/Auth/AuthService.cs
  • Modify: mws.api/Controllers/AuthController.cs

Interfaces:

  • Produces: Mws.Application.Auth.LoginCommand(LoginRequest Request) : IRequest<LoginResponse> — every later task follows this same record-wraps-existing-Request-DTO pattern.

  • Consumes: Mws.Application.Auth.LoginRequest, LoginResponse, UserDto (unchanged, from Auth/Contracts.cs); IUnitOfWork, IPasswordHasher, ITokenService (unchanged).

  • Step 1: Add the MediatR package

Run: dotnet add mws.application/mws.application.csproj package MediatR

This resolves and pins the latest stable MediatR version in mws.application.csproj.

  • Step 2: Create the Login command + handler

Create mws.application/Auth/Commands/Login.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Auth;

public record LoginCommand(LoginRequest Request) : IRequest<LoginResponse>;

public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper)
    : IRequestHandler<LoginCommand, LoginResponse>
{
    public async Task<LoginResponse> Handle(LoginCommand command, CancellationToken ct)
    {
        var username = command.Request.Username.Trim();
        var user = await uow.Users.GetByUsernameWithRoleAsync(username, ct);

        if (user is null || !passwordHasher.Verify(command.Request.Password, user.PasswordHash))
        {
            throw new UnauthorizedException("Invalid username or password");
        }

        if (!user.IsActive)
        {
            throw new ForbiddenException("Account disabled");
        }

        return new LoginResponse
        {
            Token = tokenService.CreateToken(user.Id, user.Username),
            User = mapper.Map<UserDto>(user),
        };
    }
}
  • Step 3: Delete the old service file

Delete mws.application/Auth/AuthService.cs (it contained both IAuthService and AuthService — both are now replaced by LoginCommand/LoginHandler).

  • Step 4: Update AuthController

Replace the full contents of mws.api/Controllers/AuthController.cs:

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Auth;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api/auth")]
[AllowAnonymous]
public class AuthController(ISender sender) : ControllerBase
{
    [HttpPost("login")]
    public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
    {
        var response = await sender.Send(new LoginCommand(request), ct);
        return Ok(response);
    }
}
  • Step 5: Wire MediatR and drop the old registration in DI

In mws.infrastructure/DependencyInjection.cs, add the MediatR registration right after services.AddAutoMapper(...):

        services.AddAutoMapper(cfg => { }, typeof(MappingProfile).Assembly);
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(IUnitOfWork).Assembly));

Remove the line services.AddScoped<IAuthService, AuthService>();.

Remove the now-unused using Mws.Application.Auth; only if nothing else in the file still needs it — it does (JwtOptions, ITokenService wiring live in Mws.Application.Auth too), so leave the using in place.

  • Step 6: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 7: Commit
git add mws.application/mws.application.csproj mws.application/Auth mws.infrastructure/DependencyInjection.cs mws.api/Controllers/AuthController.cs
git commit -m "$(cat <<'EOF'
Convert Auth module to MediatR command

Adds the MediatR package and wires AddMediatR in DI. Replaces
IAuthService/AuthService with LoginCommand/LoginHandler, the pattern
the remaining modules will follow.
EOF
)"

Task 2: Convert Accounts module

Files:

  • Create: mws.application/Accounts/Queries/GetAccounts.cs
  • Create: mws.application/Accounts/Commands/CreateAccount.cs
  • Create: mws.application/Accounts/Commands/UpdateAccount.cs
  • Create: mws.application/Accounts/Commands/DeleteAccount.cs
  • Create: mws.application/Accounts/Commands/ResetPassword.cs
  • Delete: mws.application/Accounts/IAccountService.cs
  • Delete: mws.application/Accounts/AccountService.cs
  • Modify: mws.api/Controllers/AccountsController.cs
  • Modify: mws.infrastructure/DependencyInjection.cs (remove AddScoped<IAccountService, AccountService>)

Interfaces:

  • Consumes: IUnitOfWork, IPasswordHasher (Mws.Application.Auth), IPermissionService/PermissionAction (Mws.Application.Permissions) — all unchanged. AccountDto, CreateAccountRequest, UpdateAccountRequest, ResetPasswordRequest from Accounts/Contracts.cs — unchanged.

  • Produces: GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest<List<AccountDto>>, CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest<AccountDto>, UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest<AccountDto>, DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest, ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest.

  • Step 1: Create the query

Create mws.application/Accounts/Queries/GetAccounts.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;

namespace Mws.Application.Accounts;

public record GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest<List<AccountDto>>;

public class GetAccountsHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
    : IRequestHandler<GetAccountsQuery, List<AccountDto>>
{
    private const string Screen = "accounts";

    public async Task<List<AccountDto>> Handle(GetAccountsQuery query, CancellationToken ct)
    {
        await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct);

        var users = await uow.Users.SearchWithRoleAsync(query.Term, null, ct);
        return mapper.Map<List<AccountDto>>(users);
    }
}
  • Step 2: Create the CreateAccount command

Create mws.application/Accounts/Commands/CreateAccount.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Auth;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Users;

namespace Mws.Application.Accounts;

public record CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest<AccountDto>;

public class CreateAccountHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper)
    : IRequestHandler<CreateAccountCommand, AccountDto>
{
    private const string Screen = "accounts";

    public async Task<AccountDto> Handle(CreateAccountCommand command, CancellationToken ct)
    {
        await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Create, ct);

        var request = command.Request;
        var username = request.Username.Trim();
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
        {
            throw new BadRequestException("Username and password are required");
        }

        if (await uow.Users.ExistsByUsernameAsync(username, ct))
        {
            throw new BadRequestException("Username already exists");
        }

        var role = await uow.Roles.GetByIdAsync(request.RoleId, ct)
            ?? throw new BadRequestException("Role not found");

        var now = DateTime.UtcNow;
        var user = new User
        {
            Id = Guid.NewGuid(),
            Username = username,
            PasswordHash = passwordHasher.Hash(request.Password),
            DisplayName = request.DisplayName.Trim(),
            RoleId = role.Id,
            IsActive = true,
            CreatedAt = now,
            UpdatedAt = now,
        };

        uow.Users.Add(user);
        await uow.SaveChangesAsync(ct);
        user.Role = role;
        return mapper.Map<AccountDto>(user);
    }
}
  • Step 3: Create the UpdateAccount command

Create mws.application/Accounts/Commands/UpdateAccount.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;

namespace Mws.Application.Accounts;

public record UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest<AccountDto>;

public class UpdateAccountHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
    : IRequestHandler<UpdateAccountCommand, AccountDto>
{
    private const string Screen = "accounts";

    public async Task<AccountDto> Handle(UpdateAccountCommand command, CancellationToken ct)
    {
        await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct);

        var user = await uow.Users.GetByIdWithRoleAsync(command.Id, ct)
            ?? throw new NotFoundException("Account not found");

        var request = command.Request;
        var role = await uow.Roles.GetByIdAsync(request.RoleId, ct)
            ?? throw new BadRequestException("Role not found");

        user.DisplayName = request.DisplayName.Trim();
        user.RoleId = role.Id;
        user.Role = role;
        user.IsActive = request.IsActive;
        user.UpdatedAt = DateTime.UtcNow;

        await uow.SaveChangesAsync(ct);
        return mapper.Map<AccountDto>(user);
    }
}
  • Step 4: Create the DeleteAccount command

Create mws.application/Accounts/Commands/DeleteAccount.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;

namespace Mws.Application.Accounts;

public record DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest;

public class DeleteAccountHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler<DeleteAccountCommand>
{
    private const string Screen = "accounts";

    public async Task Handle(DeleteAccountCommand command, CancellationToken ct)
    {
        await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Delete, ct);

        var user = await uow.Users.GetByIdAsync(command.Id, ct)
            ?? throw new NotFoundException("Account not found");

        var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(command.Id, ct);

        foreach (var projectId in soleOwnerProjectIds)
        {
            var ownerCount = await uow.Projects.CountOwnersAsync(projectId, ct);
            if (ownerCount <= 1)
            {
                throw new BadRequestException("Cannot delete an account that is the sole owner of a project");
            }
        }

        uow.Users.Remove(user);
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 5: Create the ResetPassword command

Create mws.application/Accounts/Commands/ResetPassword.cs:

using MediatR;
using Mws.Application.Auth;
using Mws.Application.Common;
using Mws.Application.Permissions;

namespace Mws.Application.Accounts;

public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest;

public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions)
    : IRequestHandler<ResetPasswordCommand>
{
    private const string Screen = "accounts";

    public async Task Handle(ResetPasswordCommand command, CancellationToken ct)
    {
        await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct);

        if (string.IsNullOrWhiteSpace(command.Request.NewPassword))
        {
            throw new BadRequestException("New password is required");
        }

        var user = await uow.Users.GetByIdAsync(command.Id, ct)
            ?? throw new NotFoundException("Account not found");

        user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword);
        user.UpdatedAt = DateTime.UtcNow;
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 6: Delete the old service files

Delete mws.application/Accounts/IAccountService.cs and mws.application/Accounts/AccountService.cs.

  • Step 7: Update AccountsController

Replace the full contents of mws.api/Controllers/AccountsController.cs:

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Accounts;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api/accounts")]
[Authorize]
public class AccountsController(ISender sender) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<AccountDto>>> GetAll([FromQuery] string? q, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetAccountsQuery(User.GetUserId(), q), ct));
    }

    [HttpPost]
    public async Task<ActionResult<AccountDto>> Create([FromBody] CreateAccountRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new CreateAccountCommand(User.GetUserId(), request), ct));
    }

    [HttpPut("{id:guid}")]
    public async Task<ActionResult<AccountDto>> Update(Guid id, [FromBody] UpdateAccountRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new UpdateAccountCommand(User.GetUserId(), id, request), ct));
    }

    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        await sender.Send(new DeleteAccountCommand(User.GetUserId(), id), ct);
        return NoContent();
    }

    [HttpPost("{id:guid}/reset-password")]
    public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct)
    {
        await sender.Send(new ResetPasswordCommand(User.GetUserId(), id, request), ct);
        return NoContent();
    }
}
  • Step 8: Remove the old DI registration

In mws.infrastructure/DependencyInjection.cs, remove the line services.AddScoped<IAccountService, AccountService>();.

  • Step 9: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 10: Commit
git add mws.application/Accounts mws.api/Controllers/AccountsController.cs mws.infrastructure/DependencyInjection.cs
git commit -m "$(cat <<'EOF'
Convert Accounts module to MediatR commands/queries

Replaces IAccountService/AccountService with one command/query per
operation, following the pattern set in the Auth module.
EOF
)"

Task 3: Convert Roles module

Files:

  • Create: mws.application/Roles/RolePermissionBuilder.cs
  • Create: mws.application/Roles/Queries/GetRoles.cs
  • Create: mws.application/Roles/Queries/GetRole.cs
  • Create: mws.application/Roles/Commands/CreateRole.cs
  • Create: mws.application/Roles/Commands/UpdateRole.cs
  • Create: mws.application/Roles/Commands/DeleteRole.cs
  • Delete: mws.application/Roles/IRoleService.cs
  • Delete: mws.application/Roles/RoleService.cs
  • Modify: mws.api/Controllers/RolesController.cs
  • Modify: mws.infrastructure/DependencyInjection.cs (remove AddScoped<IRoleService, RoleService>)

Interfaces:

  • Produces: RolePermissionBuilder.Build(List<PermissionEntryDto>) : List<RolePermission> — shared by CreateRoleHandler and UpdateRoleHandler, replacing the old RoleService.BuildPermissions private method.

  • Produces: GetRolesQuery : IRequest<List<RoleDto>>, GetRoleQuery(Guid Id) : IRequest<RoleDto>, CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>, UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>, DeleteRoleCommand(Guid Id) : IRequest.

  • Consumes: IPermissionService.EnsureAsync stays called directly from RolesController, unchanged — the permission check is NOT moved into the handlers (it wasn't in the old RoleService either; it lived in the controller).

  • Step 1: Create the shared permission-builder helper

Create mws.application/Roles/RolePermissionBuilder.cs:

using Mws.Application.Permissions;
using Mws.Domain.Roles;

namespace Mws.Application.Roles;

internal static class RolePermissionBuilder
{
    public static List<RolePermission> Build(List<PermissionEntryDto> entries)
    {
        var byScreen = entries.Where(e => ScreenCatalog.Keys.Contains(e.Screen)).ToDictionary(e => e.Screen);

        return ScreenCatalog.Keys.Select(key =>
        {
            byScreen.TryGetValue(key, out var entry);
            return new RolePermission
            {
                Screen = key,
                CanView = entry?.CanView ?? false,
                CanCreate = entry?.CanCreate ?? false,
                CanEdit = entry?.CanEdit ?? false,
                CanDelete = entry?.CanDelete ?? false,
            };
        }).ToList();
    }
}
  • Step 2: Create the queries

Create mws.application/Roles/Queries/GetRoles.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Roles;

public record GetRolesQuery : IRequest<List<RoleDto>>;

public class GetRolesHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetRolesQuery, List<RoleDto>>
{
    public async Task<List<RoleDto>> Handle(GetRolesQuery query, CancellationToken ct)
    {
        var roles = await uow.Roles.GetAllWithPermissionsAsync(ct);
        return mapper.Map<List<RoleDto>>(roles);
    }
}

Create mws.application/Roles/Queries/GetRole.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Roles;

public record GetRoleQuery(Guid Id) : IRequest<RoleDto>;

public class GetRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetRoleQuery, RoleDto>
{
    public async Task<RoleDto> Handle(GetRoleQuery query, CancellationToken ct)
    {
        var role = await uow.Roles.GetByIdWithPermissionsAsync(query.Id, ct)
            ?? throw new NotFoundException("Role not found");
        return mapper.Map<RoleDto>(role);
    }
}
  • Step 3: Create the CreateRole command

Create mws.application/Roles/Commands/CreateRole.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Roles;

namespace Mws.Application.Roles;

public record CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>;

public class CreateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateRoleCommand, RoleDto>
{
    public async Task<RoleDto> Handle(CreateRoleCommand command, CancellationToken ct)
    {
        var name = command.Request.Name.Trim();
        if (string.IsNullOrWhiteSpace(name))
        {
            throw new BadRequestException("Role name is required");
        }

        if (await uow.Roles.ExistsByNameAsync(name, null, ct))
        {
            throw new BadRequestException("Role name already exists");
        }

        var now = DateTime.UtcNow;
        var role = new Role
        {
            Id = Guid.NewGuid(),
            Name = name,
            IsSystem = false,
            CreatedAt = now,
            UpdatedAt = now,
            Permissions = RolePermissionBuilder.Build(command.Request.Permissions),
        };

        uow.Roles.Add(role);
        await uow.SaveChangesAsync(ct);
        return mapper.Map<RoleDto>(role);
    }
}
  • Step 4: Create the UpdateRole command

Create mws.application/Roles/Commands/UpdateRole.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Roles;

public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>;

public class UpdateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateRoleCommand, RoleDto>
{
    public async Task<RoleDto> Handle(UpdateRoleCommand command, CancellationToken ct)
    {
        var role = await uow.Roles.GetByIdWithPermissionsAsync(command.Id, ct)
            ?? throw new NotFoundException("Role not found");

        var name = command.Request.Name.Trim();
        if (string.IsNullOrWhiteSpace(name))
        {
            throw new BadRequestException("Role name is required");
        }

        if (await uow.Roles.ExistsByNameAsync(name, command.Id, ct))
        {
            throw new BadRequestException("Role name already exists");
        }

        role.Name = name;
        role.UpdatedAt = DateTime.UtcNow;

        uow.Roles.RemovePermissions(role.Permissions.ToList());
        role.Permissions = RolePermissionBuilder.Build(command.Request.Permissions);
        foreach (var p in role.Permissions)
        {
            p.RoleId = role.Id;
        }

        await uow.SaveChangesAsync(ct);
        return mapper.Map<RoleDto>(role);
    }
}
  • Step 5: Create the DeleteRole command

Create mws.application/Roles/Commands/DeleteRole.cs:

using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Roles;

public record DeleteRoleCommand(Guid Id) : IRequest;

public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler<DeleteRoleCommand>
{
    public async Task Handle(DeleteRoleCommand command, CancellationToken ct)
    {
        var role = await uow.Roles.GetByIdAsync(command.Id, ct)
            ?? throw new NotFoundException("Role not found");

        if (role.IsSystem)
        {
            throw new BadRequestException("Cannot delete a system role");
        }

        if (await uow.Users.ExistsByRoleIdAsync(command.Id, ct))
        {
            throw new BadRequestException("Cannot delete a role that is assigned to accounts");
        }

        uow.Roles.Remove(role);
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 6: Delete the old service files

Delete mws.application/Roles/IRoleService.cs and mws.application/Roles/RoleService.cs.

  • Step 7: Update RolesController

Replace the full contents of mws.api/Controllers/RolesController.cs (the inline IPermissionService.EnsureAsync calls stay exactly as they were):

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Permissions;
using Mws.Application.Roles;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api/roles")]
[Authorize]
public class RolesController(ISender sender, IPermissionService permissions) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<RoleDto>>> GetAll(CancellationToken ct)
    {
        await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct);
        return Ok(await sender.Send(new GetRolesQuery(), ct));
    }

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<RoleDto>> Get(Guid id, CancellationToken ct)
    {
        await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct);
        return Ok(await sender.Send(new GetRoleQuery(id), ct));
    }

    [HttpPost]
    public async Task<ActionResult<RoleDto>> Create([FromBody] SaveRoleRequest request, CancellationToken ct)
    {
        await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Create, ct);
        return Ok(await sender.Send(new CreateRoleCommand(request), ct));
    }

    [HttpPut("{id:guid}")]
    public async Task<ActionResult<RoleDto>> Update(Guid id, [FromBody] SaveRoleRequest request, CancellationToken ct)
    {
        await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Edit, ct);
        return Ok(await sender.Send(new UpdateRoleCommand(id, request), ct));
    }

    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Delete, ct);
        await sender.Send(new DeleteRoleCommand(id), ct);
        return NoContent();
    }
}
  • Step 8: Remove the old DI registration

In mws.infrastructure/DependencyInjection.cs, remove the line services.AddScoped<IRoleService, RoleService>();.

  • Step 9: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 10: Commit
git add mws.application/Roles mws.api/Controllers/RolesController.cs mws.infrastructure/DependencyInjection.cs
git commit -m "$(cat <<'EOF'
Convert Roles module to MediatR commands/queries

Replaces IRoleService/RoleService. The permission-builder logic moves
to a shared RolePermissionBuilder static helper used by both the
create and update handlers. RolesController keeps its inline
IPermissionService checks unchanged.
EOF
)"

Task 4: Convert Projects module (project CRUD, not membership)

Files:

  • Create: mws.application/Projects/ProjectAccess.cs
  • Create: mws.application/Projects/Queries/GetProjects.cs
  • Create: mws.application/Projects/Queries/GetProject.cs
  • Create: mws.application/Projects/Queries/SearchProjects.cs
  • Create: mws.application/Projects/Queries/GetProjectOverview.cs
  • Create: mws.application/Projects/Commands/CreateProject.cs
  • Create: mws.application/Projects/Commands/UpdateProject.cs
  • Create: mws.application/Projects/Commands/ArchiveProject.cs
  • Modify: mws.application/Projects/IProjectService.cs (remove IProjectService, keep IProjectMemberService — Task 5 removes it)
  • Delete: mws.application/Projects/ProjectService.cs
  • Modify: mws.api/Controllers/ProjectsController.cs
  • Modify: mws.infrastructure/DependencyInjection.cs (remove AddScoped<IProjectService, ProjectService>)

Interfaces:

  • Produces: ProjectAccess.GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) : Task<Project> — shared by GetProjectHandler, UpdateProjectHandler, ArchiveProjectHandler, GetProjectOverviewHandler.

  • Produces: GetProjectsQuery(Guid UserId) : IRequest<List<ProjectDto>>, GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectDto>, SearchProjectsQuery(Guid UserId, string Term) : IRequest<List<ProjectDto>>, GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectOverviewDto>, CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>, UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>, ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest.

  • Note: IProjectMemberService (Task 5's target) stays in IProjectService.cs for this task — do not delete it yet, ProjectMembersController still depends on it until Task 5.

  • Step 1: Create the shared project-access helper

Create mws.application/Projects/ProjectAccess.cs:

using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

internal static class ProjectAccess
{
    public static async Task<Project> GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct)
    {
        return await uow.Projects.GetForUserAsync(userId, projectId, ct)
            ?? throw new NotFoundException("Project not found");
    }
}
  • Step 2: Create the queries

Create mws.application/Projects/Queries/GetProjects.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Projects;

public record GetProjectsQuery(Guid UserId) : IRequest<List<ProjectDto>>;

public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectsQuery, List<ProjectDto>>
{
    public async Task<List<ProjectDto>> Handle(GetProjectsQuery query, CancellationToken ct)
    {
        var projects = await uow.Projects.GetForUserAsync(query.UserId, ct);
        return mapper.Map<List<ProjectDto>>(projects);
    }
}

Create mws.application/Projects/Queries/GetProject.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Projects;

public record GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectDto>;

public class GetProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectQuery, ProjectDto>
{
    public async Task<ProjectDto> Handle(GetProjectQuery query, CancellationToken ct)
    {
        var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct);
        return mapper.Map<ProjectDto>(project);
    }
}

Create mws.application/Projects/Queries/SearchProjects.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Projects;

public record SearchProjectsQuery(Guid UserId, string Term) : IRequest<List<ProjectDto>>;

public class SearchProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<SearchProjectsQuery, List<ProjectDto>>
{
    public async Task<List<ProjectDto>> Handle(SearchProjectsQuery query, CancellationToken ct)
    {
        var projects = await uow.Projects.SearchForUserAsync(query.UserId, query.Term, ct);
        return mapper.Map<List<ProjectDto>>(projects);
    }
}

Create mws.application/Projects/Queries/GetProjectOverview.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Documents;

namespace Mws.Application.Projects;

public record GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectOverviewDto>;

public class GetProjectOverviewHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectOverviewQuery, ProjectOverviewDto>
{
    public async Task<ProjectOverviewDto> Handle(GetProjectOverviewQuery query, CancellationToken ct)
    {
        var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct);

        var memberCount = await uow.Projects.CountMembersAsync(query.ProjectId, ct);
        var documentCount = await uow.Documents.CountByTypeForProjectAsync(query.ProjectId, DocumentType.Document, ct);

        var taskCounts = await uow.Tasks.CountByStatusForProjectAsync(query.ProjectId, ct);
        var taskCountsByStatus = taskCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value);

        var recentTasks = await uow.Tasks.GetRecentForProjectAsync(query.ProjectId, 5, ct);
        var recentDocuments = await uow.Documents.GetRecentForProjectAsync(query.ProjectId, DocumentType.Document, 5, ct);

        return new ProjectOverviewDto
        {
            Project = mapper.Map<ProjectDto>(project),
            MemberCount = memberCount,
            DocumentCount = documentCount,
            TaskCountsByStatus = taskCountsByStatus,
            RecentTasks = mapper.Map<List<RecentTaskDto>>(recentTasks),
            RecentDocuments = mapper.Map<List<RecentDocumentDto>>(recentDocuments),
        };
    }
}
  • Step 3: Create the CreateProject command

Create mws.application/Projects/Commands/CreateProject.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>;

public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateProjectCommand, ProjectDto>
{
    public async Task<ProjectDto> Handle(CreateProjectCommand command, CancellationToken ct)
    {
        var request = command.Request;
        if (string.IsNullOrWhiteSpace(request.Name))
        {
            throw new BadRequestException("Project name is required");
        }

        var now = DateTime.UtcNow;
        var project = new Project
        {
            Id = Guid.NewGuid(),
            Name = request.Name.Trim(),
            Description = request.Description,
            Status = ProjectStatus.Active,
            CreatedAt = now,
            UpdatedAt = now,
        };

        project.Members.Add(new ProjectMember
        {
            ProjectId = project.Id,
            UserId = command.UserId,
            Role = MemberRole.Owner,
        });

        uow.Projects.Add(project);
        await uow.SaveChangesAsync(ct);
        return mapper.Map<ProjectDto>(project);
    }
}
  • Step 4: Create the UpdateProject command

Create mws.application/Projects/Commands/UpdateProject.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>;

public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateProjectCommand, ProjectDto>
{
    public async Task<ProjectDto> Handle(UpdateProjectCommand command, CancellationToken ct)
    {
        var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct);

        if (MemberRole.Owner != await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct))
        {
            throw new ForbiddenException("Only the project owner can update the project");
        }

        var request = command.Request;
        if (string.IsNullOrWhiteSpace(request.Name))
        {
            throw new BadRequestException("Project name is required");
        }

        project.Name = request.Name.Trim();
        project.Description = request.Description;
        project.Status = request.Status;
        project.UpdatedAt = DateTime.UtcNow;

        await uow.SaveChangesAsync(ct);
        return mapper.Map<ProjectDto>(project);
    }
}
  • Step 5: Create the ArchiveProject command

Create mws.application/Projects/Commands/ArchiveProject.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest;

public class ArchiveProjectHandler(IUnitOfWork uow) : IRequestHandler<ArchiveProjectCommand>
{
    public async Task Handle(ArchiveProjectCommand command, CancellationToken ct)
    {
        var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct);

        if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
        {
            throw new ForbiddenException("Only the project owner can archive the project");
        }

        project.Status = ProjectStatus.Archived;
        project.UpdatedAt = DateTime.UtcNow;
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 6: Remove IProjectService from IProjectService.cs, keep IProjectMemberService

Replace the full contents of mws.application/Projects/IProjectService.cs:

namespace Mws.Application.Projects;

public interface IProjectMemberService
{
    Task<List<ProjectMemberDto>> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default);
    Task<ProjectMemberDto> AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default);
    Task<ProjectMemberDto> UpdateMemberDocumentPermissionsAsync(Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default);
    Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default);
}
  • Step 7: Delete ProjectService.cs

Delete mws.application/Projects/ProjectService.cs.

  • Step 8: Update ProjectsController

Replace the full contents of mws.api/Controllers/ProjectsController.cs:

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Projects;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api/projects")]
[Authorize]
public class ProjectsController(ISender sender) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<ProjectDto>>> GetAll(CancellationToken ct)
    {
        return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId()), ct));
    }

    [HttpGet("search")]
    public async Task<ActionResult<List<ProjectDto>>> Search([FromQuery] string? q, CancellationToken ct)
    {
        return Ok(await sender.Send(new SearchProjectsQuery(User.GetUserId(), q ?? string.Empty), ct));
    }

    [HttpGet("{projectId:guid}")]
    public async Task<ActionResult<ProjectDto>> Get(Guid projectId, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetProjectQuery(User.GetUserId(), projectId), ct));
    }

    [HttpGet("{projectId:guid}/overview")]
    public async Task<ActionResult<ProjectOverviewDto>> Overview(Guid projectId, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetProjectOverviewQuery(User.GetUserId(), projectId), ct));
    }

    [HttpPost]
    public async Task<ActionResult<ProjectDto>> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
    {
        var result = await sender.Send(new CreateProjectCommand(User.GetUserId(), request), ct);
        return CreatedAtAction(nameof(Get), new { projectId = result.Id }, result);
    }

    [HttpPut("{projectId:guid}")]
    public async Task<ActionResult<ProjectDto>> Update(Guid projectId, [FromBody] UpdateProjectRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new UpdateProjectCommand(User.GetUserId(), projectId, request), ct));
    }

    [HttpDelete("{projectId:guid}")]
    public async Task<IActionResult> Delete(Guid projectId, CancellationToken ct)
    {
        await sender.Send(new ArchiveProjectCommand(User.GetUserId(), projectId), ct);
        return NoContent();
    }
}
  • Step 9: Remove the old DI registration

In mws.infrastructure/DependencyInjection.cs, remove the line services.AddScoped<IProjectService, ProjectService>();. Leave services.AddScoped<IProjectMemberService, ProjectMemberService>(); in place — Task 5 removes it.

  • Step 10: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 11: Commit
git add mws.application/Projects mws.api/Controllers/ProjectsController.cs mws.infrastructure/DependencyInjection.cs
git commit -m "$(cat <<'EOF'
Convert Projects module to MediatR commands/queries

Replaces IProjectService/ProjectService. IProjectMemberService is
left in place for now — ProjectMembersController still depends on it
until the next task converts it too.
EOF
)"

Task 5: Convert ProjectMembers module

Files:

  • Create: mws.application/Projects/Queries/GetProjectMembers.cs
  • Create: mws.application/Projects/Commands/AddProjectMember.cs
  • Create: mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs
  • Create: mws.application/Projects/Commands/RemoveProjectMember.cs
  • Delete: mws.application/Projects/IProjectService.cs (now contains only IProjectMemberService, no longer needed)
  • Delete: mws.application/Projects/ProjectMemberService.cs
  • Modify: mws.api/Controllers/ProjectMembersController.cs
  • Modify: mws.infrastructure/DependencyInjection.cs (remove AddScoped<IProjectMemberService, ProjectMemberService>)

Interfaces:

  • Produces: GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest<List<ProjectMemberDto>>, AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>, UpdateMemberDocumentPermissionsCommand(Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>, RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest.

  • Consumes: IUnitOfWork.Projects.GetMemberRoleAsync(projectId, userId, ct) called directly (not wrapped in a helper — it was already a one-line delegate in the old service).

  • Step 1: Create the query

Create mws.application/Projects/Queries/GetProjectMembers.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest<List<ProjectMemberDto>>;

public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProjectMembersQuery, List<ProjectMemberDto>>
{
    public async Task<List<ProjectMemberDto>> Handle(GetProjectMembersQuery query, CancellationToken ct)
    {
        var isMember = await uow.Projects.IsMemberAsync(query.ProjectId, query.UserId, ct);
        if (!isMember)
        {
            throw new NotFoundException("Project not found");
        }

        var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, ct);
        var permissions = await uow.Projects.GetMemberPermissionsAsync(query.ProjectId, ProjectPermissionScreens.Documents, ct);

        return members.Select(m =>
        {
            permissions.TryGetValue(m.UserId, out var p);
            return new ProjectMemberDto
            {
                UserId = m.UserId,
                Username = m.User.Username,
                DisplayName = m.User.DisplayName,
                Role = m.Role,
                CanViewDocuments = m.Role == MemberRole.Owner || (p?.CanView ?? false),
                CanCreateDocuments = m.Role == MemberRole.Owner || (p?.CanCreate ?? false),
                CanEditDocuments = m.Role == MemberRole.Owner || (p?.CanEdit ?? false),
                CanDeleteDocuments = m.Role == MemberRole.Owner || (p?.CanDelete ?? false),
            };
        }).ToList();
    }
}
  • Step 2: Create the AddProjectMember command

Create mws.application/Projects/Commands/AddProjectMember.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>;

public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<AddProjectMemberCommand, ProjectMemberDto>
{
    public async Task<ProjectMemberDto> Handle(AddProjectMemberCommand command, CancellationToken ct)
    {
        if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
        {
            throw new ForbiddenException("Only the project owner can add members");
        }

        var request = command.Request;
        var user = await uow.Users.GetByIdAsync(request.UserId, ct)
            ?? throw new NotFoundException("User not found");

        var already = await uow.Projects.IsMemberAsync(command.ProjectId, request.UserId, ct);
        if (already)
        {
            throw new BadRequestException("User is already a member of this project");
        }

        var role = request.Role is MemberRole.Owner or MemberRole.Member ? request.Role : MemberRole.Member;
        var member = new ProjectMember
        {
            ProjectId = command.ProjectId,
            UserId = request.UserId,
            Role = role,
        };

        uow.Projects.AddMember(member);
        uow.Projects.AddMemberPermission(new ProjectMemberPermission
        {
            ProjectId = command.ProjectId,
            UserId = request.UserId,
            Screen = ProjectPermissionScreens.Documents,
            CanView = true,
            CanCreate = role == MemberRole.Owner,
            CanEdit = role == MemberRole.Owner,
            CanDelete = role == MemberRole.Owner,
        });
        await uow.SaveChangesAsync(ct);

        return new ProjectMemberDto
        {
            UserId = user.Id,
            Username = user.Username,
            DisplayName = user.DisplayName,
            Role = member.Role,
            CanViewDocuments = true,
            CanCreateDocuments = role == MemberRole.Owner,
            CanEditDocuments = role == MemberRole.Owner,
            CanDeleteDocuments = role == MemberRole.Owner,
        };
    }
}
  • Step 3: Create the UpdateMemberDocumentPermissions command

Create mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record UpdateMemberDocumentPermissionsCommand(
    Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>;

public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow)
    : IRequestHandler<UpdateMemberDocumentPermissionsCommand, ProjectMemberDto>
{
    public async Task<ProjectMemberDto> Handle(UpdateMemberDocumentPermissionsCommand command, CancellationToken ct)
    {
        if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
        {
            throw new ForbiddenException("Only the project owner can change member permissions");
        }

        var member = await uow.Projects.GetMemberWithUserAsync(command.ProjectId, command.MemberUserId, ct)
            ?? throw new NotFoundException("Member not found in project");

        if (member.Role == MemberRole.Owner)
        {
            throw new BadRequestException("Owner permissions cannot be changed");
        }

        var permission = await uow.Projects.GetMemberPermissionAsync(command.ProjectId, command.MemberUserId, ProjectPermissionScreens.Documents, ct);
        if (permission is null)
        {
            permission = new ProjectMemberPermission { ProjectId = command.ProjectId, UserId = command.MemberUserId, Screen = ProjectPermissionScreens.Documents };
            uow.Projects.AddMemberPermission(permission);
        }

        var request = command.Request;
        permission.CanView = request.CanViewDocuments;
        permission.CanCreate = request.CanCreateDocuments;
        permission.CanEdit = request.CanEditDocuments;
        permission.CanDelete = request.CanDeleteDocuments;
        await uow.SaveChangesAsync(ct);

        return new ProjectMemberDto
        {
            UserId = member.UserId,
            Username = member.User.Username,
            DisplayName = member.User.DisplayName,
            Role = member.Role,
            CanViewDocuments = permission.CanView,
            CanCreateDocuments = permission.CanCreate,
            CanEditDocuments = permission.CanEdit,
            CanDeleteDocuments = permission.CanDelete,
        };
    }
}
  • Step 4: Create the RemoveProjectMember command

Create mws.application/Projects/Commands/RemoveProjectMember.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Domain.Projects;

namespace Mws.Application.Projects;

public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest;

public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<RemoveProjectMemberCommand>
{
    public async Task Handle(RemoveProjectMemberCommand command, CancellationToken ct)
    {
        if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
        {
            throw new ForbiddenException("Only the project owner can remove members");
        }

        var member = await uow.Projects.GetMemberAsync(command.ProjectId, command.MemberUserId, ct)
            ?? throw new NotFoundException("Member not found in project");

        var owners = await uow.Projects.CountOwnersAsync(command.ProjectId, ct);

        if (member.Role == MemberRole.Owner && owners <= 1)
        {
            throw new BadRequestException("Cannot remove the last owner of the project");
        }

        uow.Projects.RemoveMember(member);
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 5: Delete the old service files

Delete mws.application/Projects/IProjectService.cs (it now contains only IProjectMemberService, which is fully replaced) and mws.application/Projects/ProjectMemberService.cs.

  • Step 6: Update ProjectMembersController

Replace the full contents of mws.api/Controllers/ProjectMembersController.cs:

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Projects;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api/projects/{projectId:guid}/members")]
[Authorize]
public class ProjectMembersController(ISender sender) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<ProjectMemberDto>>> GetAll(Guid projectId, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId), ct));
    }

    [HttpPost]
    public async Task<ActionResult<ProjectMemberDto>> Add(Guid projectId, [FromBody] AddMemberRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new AddProjectMemberCommand(User.GetUserId(), projectId, request), ct));
    }

    [HttpPut("{userId:guid}/document-permissions")]
    public async Task<ActionResult<ProjectMemberDto>> UpdateDocumentPermissions(
        Guid projectId, Guid userId, [FromBody] UpdateMemberDocumentPermissionsRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new UpdateMemberDocumentPermissionsCommand(User.GetUserId(), projectId, userId, request), ct));
    }

    [HttpDelete("{userId:guid}")]
    public async Task<IActionResult> Remove(Guid projectId, Guid userId, CancellationToken ct)
    {
        await sender.Send(new RemoveProjectMemberCommand(User.GetUserId(), projectId, userId), ct);
        return NoContent();
    }
}
  • Step 7: Remove the old DI registration

In mws.infrastructure/DependencyInjection.cs, remove the line services.AddScoped<IProjectMemberService, ProjectMemberService>();.

  • Step 8: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 9: Commit
git add mws.application/Projects mws.api/Controllers/ProjectMembersController.cs mws.infrastructure/DependencyInjection.cs
git commit -m "$(cat <<'EOF'
Convert ProjectMembers module to MediatR commands/queries

Replaces IProjectMemberService/ProjectMemberService, completing the
Projects module conversion.
EOF
)"

Task 6: Convert Documents module

Files:

  • Create: mws.application/Documents/DocumentAccess.cs
  • Create: mws.application/Documents/Queries/GetDocumentTree.cs
  • Create: mws.application/Documents/Queries/GetDocument.cs
  • Create: mws.application/Documents/Queries/SearchDocuments.cs
  • Create: mws.application/Documents/Commands/CreateDocument.cs
  • Create: mws.application/Documents/Commands/UpdateDocument.cs
  • Create: mws.application/Documents/Commands/MoveDocument.cs
  • Create: mws.application/Documents/Commands/DeleteDocument.cs
  • Delete: mws.application/Documents/IDocumentService.cs
  • Delete: mws.application/Documents/DocumentService.cs
  • Modify: mws.api/Controllers/DocumentsController.cs
  • Modify: mws.infrastructure/DependencyInjection.cs (remove AddScoped<IDocumentService, DocumentService>)

Interfaces:

  • Produces: DocumentAccess.EnsureDocumentPermissionAsync(uow, userId, projectId, action, ct), DocumentAccess.HasDocumentPermissionAsync(uow, member, action, ct) : Task<bool>, DocumentAccess.GetDocumentForUserAsync(uow, userId, documentId, ct) : Task<Document>, DocumentAccess.DeleteDescendantsAsync(uow, parentId, ct) — shared across every Documents handler below.

  • Produces: GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest<List<DocumentNodeDto>>, GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest<DocumentDto>, SearchDocumentsQuery(Guid UserId, string Term) : IRequest<List<DocumentNodeDto>>, CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>, UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>, MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest, DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest.

  • Step 1: Create the shared document-access helper

Create mws.application/Documents/DocumentAccess.cs:

using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;
using Mws.Domain.Projects;

namespace Mws.Application.Documents;

internal static class DocumentAccess
{
    public static async Task EnsureDocumentPermissionAsync(
        IUnitOfWork uow, Guid userId, Guid projectId, PermissionAction action, CancellationToken ct)
    {
        var member = await uow.Projects.GetMemberAsync(projectId, userId, ct)
            ?? throw new NotFoundException("Project not found");

        if (!await HasDocumentPermissionAsync(uow, member, action, ct))
        {
            throw new ForbiddenException("You do not have permission to access documents in this project");
        }
    }

    public static async Task<bool> HasDocumentPermissionAsync(
        IUnitOfWork uow, ProjectMember member, PermissionAction action, CancellationToken ct)
    {
        if (member.Role == MemberRole.Owner)
        {
            return true;
        }

        var permission = await uow.Projects.GetMemberPermissionAsync(member.ProjectId, member.UserId, ProjectPermissionScreens.Documents, ct);

        return action switch
        {
            PermissionAction.View => permission?.CanView ?? false,
            PermissionAction.Create => permission?.CanCreate ?? false,
            PermissionAction.Edit => permission?.CanEdit ?? false,
            PermissionAction.Delete => permission?.CanDelete ?? false,
            _ => false,
        };
    }

    public static async Task<Document> GetDocumentForUserAsync(IUnitOfWork uow, Guid userId, Guid documentId, CancellationToken ct)
    {
        var doc = await uow.Documents.GetByIdAsync(documentId, ct)
            ?? throw new NotFoundException("Document not found");

        var member = await uow.Projects.GetMemberAsync(doc.ProjectId, userId, ct);

        if (member is null)
        {
            throw new NotFoundException("Document not found");
        }

        if (!await HasDocumentPermissionAsync(uow, member, PermissionAction.View, ct))
        {
            throw new ForbiddenException("You do not have permission to view this document");
        }

        return doc;
    }

    public static async Task DeleteDescendantsAsync(IUnitOfWork uow, Guid parentId, CancellationToken ct)
    {
        var children = await uow.Documents.GetChildrenAsync(parentId, ct);
        foreach (var child in children)
        {
            await DeleteDescendantsAsync(uow, child.Id, ct);
            uow.Documents.Remove(child);
        }
    }
}
  • Step 2: Create the queries

Create mws.application/Documents/Queries/GetDocumentTree.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;

namespace Mws.Application.Documents;

public record GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest<List<DocumentNodeDto>>;

public class GetDocumentTreeHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetDocumentTreeQuery, List<DocumentNodeDto>>
{
    public async Task<List<DocumentNodeDto>> Handle(GetDocumentTreeQuery query, CancellationToken ct)
    {
        await DocumentAccess.EnsureDocumentPermissionAsync(uow, query.UserId, query.ProjectId, PermissionAction.View, ct);

        var docs = await uow.Documents.GetTreeForProjectAsync(query.ProjectId, ct);

        var nodes = docs.ToDictionary(d => d.Id, d => mapper.Map<DocumentNodeDto>(d));

        var roots = new List<DocumentNodeDto>();
        foreach (var node in nodes.Values)
        {
            if (node.ParentId is { } parentId && nodes.TryGetValue(parentId, out var parent))
            {
                parent.Children.Add(node);
            }
            else
            {
                roots.Add(node);
            }
        }

        return roots;
    }
}

Create mws.application/Documents/Queries/GetDocument.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Documents;

public record GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest<DocumentDto>;

public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetDocumentQuery, DocumentDto>
{
    public async Task<DocumentDto> Handle(GetDocumentQuery query, CancellationToken ct)
    {
        var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct);
        return mapper.Map<DocumentDto>(doc);
    }
}

Create mws.application/Documents/Queries/SearchDocuments.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Documents;

public record SearchDocumentsQuery(Guid UserId, string Term) : IRequest<List<DocumentNodeDto>>;

public class SearchDocumentsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<SearchDocumentsQuery, List<DocumentNodeDto>>
{
    public async Task<List<DocumentNodeDto>> Handle(SearchDocumentsQuery query, CancellationToken ct)
    {
        var projectIds = await uow.Projects.GetDocumentViewableProjectIdsAsync(query.UserId, ct);
        var results = await uow.Documents.SearchAsync(projectIds, query.Term, 20, ct);
        return mapper.Map<List<DocumentNodeDto>>(results);
    }
}
  • Step 3: Create the CreateDocument command

Create mws.application/Documents/Commands/CreateDocument.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;

namespace Mws.Application.Documents;

public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>;

public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateDocumentCommand, DocumentDto>
{
    public async Task<DocumentDto> Handle(CreateDocumentCommand command, CancellationToken ct)
    {
        await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, command.ProjectId, PermissionAction.Create, ct);

        var request = command.Request;
        if (string.IsNullOrWhiteSpace(request.Title))
        {
            throw new BadRequestException("Title is required");
        }

        if (request.ParentId is { } parentId)
        {
            var parent = await uow.Documents.GetInProjectAsync(parentId, command.ProjectId, ct)
                ?? throw new BadRequestException("Parent document not found");
            if (parent.Type != DocumentType.Folder)
            {
                throw new BadRequestException("Parent must be a folder");
            }
        }

        if (request.Type == DocumentType.Folder && !string.IsNullOrWhiteSpace(request.Content))
        {
            throw new BadRequestException("Folders cannot have content");
        }

        var now = DateTime.UtcNow;
        var doc = new Document
        {
            Id = Guid.NewGuid(),
            ProjectId = command.ProjectId,
            ParentId = request.ParentId,
            Title = request.Title.Trim(),
            Type = request.Type,
            Content = request.Type == DocumentType.Document ? request.Content : null,
            CreatedBy = command.UserId,
            CreatedAt = now,
            UpdatedBy = command.UserId,
            UpdatedAt = now,
        };

        uow.Documents.Add(doc);
        await uow.SaveChangesAsync(ct);
        return mapper.Map<DocumentDto>(doc);
    }
}
  • Step 4: Create the UpdateDocument command

Create mws.application/Documents/Commands/UpdateDocument.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;

namespace Mws.Application.Documents;

public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>;

public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateDocumentCommand, DocumentDto>
{
    public async Task<DocumentDto> Handle(UpdateDocumentCommand command, CancellationToken ct)
    {
        var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
        await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Edit, ct);

        var request = command.Request;
        if (string.IsNullOrWhiteSpace(request.Title))
        {
            throw new BadRequestException("Title is required");
        }

        doc.Title = request.Title.Trim();
        if (doc.Type == DocumentType.Document)
        {
            doc.Content = request.Content;
            doc.UpdatedAt = DateTime.UtcNow;
            doc.UpdatedBy = command.UserId;
        }
        else
        {
            doc.UpdatedAt = DateTime.UtcNow;
            doc.UpdatedBy = command.UserId;
        }

        await uow.SaveChangesAsync(ct);
        return mapper.Map<DocumentDto>(doc);
    }
}
  • Step 5: Create the MoveDocument command

Create mws.application/Documents/Commands/MoveDocument.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;
using Mws.Domain.Documents;

namespace Mws.Application.Documents;

public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest;

public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler<MoveDocumentCommand>
{
    public async Task Handle(MoveDocumentCommand command, CancellationToken ct)
    {
        var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
        await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Edit, ct);

        if (command.NewParentId == command.DocumentId)
        {
            throw new BadRequestException("A document cannot be moved into itself");
        }

        if (command.NewParentId is { } parentId)
        {
            var parent = await uow.Documents.GetByIdAsync(parentId, ct)
                ?? throw new NotFoundException("Parent folder not found");
            if (parent.ProjectId != doc.ProjectId)
            {
                throw new BadRequestException("Parent must belong to the same project");
            }
            if (parent.Type != DocumentType.Folder)
            {
                throw new BadRequestException("Parent must be a folder");
            }

            var cursor = parent.ParentId;
            while (cursor is not null)
            {
                if (cursor == command.DocumentId)
                {
                    throw new BadRequestException("A folder cannot be moved into its own descendant");
                }
                cursor = await uow.Documents.GetParentIdAsync(cursor.Value, ct);
            }
        }

        doc.ParentId = command.NewParentId;
        doc.UpdatedAt = DateTime.UtcNow;
        doc.UpdatedBy = command.UserId;
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 6: Create the DeleteDocument command

Create mws.application/Documents/Commands/DeleteDocument.cs:

using MediatR;
using Mws.Application.Common;
using Mws.Application.Permissions;

namespace Mws.Application.Documents;

public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest;

public class DeleteDocumentHandler(IUnitOfWork uow) : IRequestHandler<DeleteDocumentCommand>
{
    public async Task Handle(DeleteDocumentCommand command, CancellationToken ct)
    {
        var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
        await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Delete, ct);

        await DocumentAccess.DeleteDescendantsAsync(uow, command.DocumentId, ct);
        uow.Documents.Remove(doc);
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 7: Delete the old service files

Delete mws.application/Documents/IDocumentService.cs and mws.application/Documents/DocumentService.cs.

  • Step 8: Update DocumentsController

Replace the full contents of mws.api/Controllers/DocumentsController.cs:

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Documents;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api")]
[Authorize]
public class DocumentsController(ISender sender) : ControllerBase
{
    [HttpGet("documents/search")]
    public async Task<ActionResult<List<DocumentNodeDto>>> Search([FromQuery] string? q, CancellationToken ct)
    {
        return Ok(await sender.Send(new SearchDocumentsQuery(User.GetUserId(), q ?? string.Empty), ct));
    }

    [HttpGet("projects/{projectId:guid}/documents")]
    public async Task<ActionResult<List<DocumentNodeDto>>> GetTree(Guid projectId, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetDocumentTreeQuery(User.GetUserId(), projectId), ct));
    }

    [HttpPost("projects/{projectId:guid}/documents")]
    public async Task<ActionResult<DocumentDto>> Create(Guid projectId, [FromBody] CreateDocumentRequest request, CancellationToken ct)
    {
        var result = await sender.Send(new CreateDocumentCommand(User.GetUserId(), projectId, request), ct);
        return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
    }

    [HttpGet("documents/{id:guid}")]
    public async Task<ActionResult<DocumentDto>> Get(Guid id, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct));
    }

    [HttpPut("documents/{id:guid}")]
    public async Task<ActionResult<DocumentDto>> Update(Guid id, [FromBody] UpdateDocumentRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new UpdateDocumentCommand(User.GetUserId(), id, request), ct));
    }

    [HttpPut("documents/{id:guid}/move")]
    public async Task<ActionResult<DocumentDto>> Move(Guid id, [FromBody] MoveDocumentRequest request, CancellationToken ct)
    {
        await sender.Send(new MoveDocumentCommand(User.GetUserId(), id, request.NewParentId), ct);
        return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct));
    }

    [HttpDelete("documents/{id:guid}")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        await sender.Send(new DeleteDocumentCommand(User.GetUserId(), id), ct);
        return NoContent();
    }
}
  • Step 9: Remove the old DI registration

In mws.infrastructure/DependencyInjection.cs, remove the line services.AddScoped<IDocumentService, DocumentService>();.

  • Step 10: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 11: Commit
git add mws.application/Documents mws.api/Controllers/DocumentsController.cs mws.infrastructure/DependencyInjection.cs
git commit -m "$(cat <<'EOF'
Convert Documents module to MediatR commands/queries

Replaces IDocumentService/DocumentService. The permission-check and
descendant-deletion logic moves to a shared DocumentAccess static
helper used by all seven handlers.
EOF
)"

Task 7: Convert Tasks module

Files:

  • Create: mws.application/Tasks/TaskAccess.cs
  • Create: mws.application/Tasks/Queries/GetTasks.cs
  • Create: mws.application/Tasks/Queries/GetTask.cs
  • Create: mws.application/Tasks/Queries/SearchTasks.cs
  • Create: mws.application/Tasks/Commands/CreateTask.cs
  • Create: mws.application/Tasks/Commands/UpdateTask.cs
  • Create: mws.application/Tasks/Commands/DeleteTask.cs
  • Delete: mws.application/Tasks/ITaskService.cs
  • Delete: mws.application/Tasks/TaskService.cs
  • Modify: mws.api/Controllers/TasksController.cs
  • Modify: mws.infrastructure/DependencyInjection.cs (remove AddScoped<ITaskService, TaskService>)

Interfaces:

  • Produces: TaskAccess.GetDtoAsync(uow, mapper, id, ct) : Task<TaskDto>, TaskAccess.GetTaskForUserAsync(uow, userId, taskId, ct) : Task<TaskItem>, TaskAccess.EnsureMemberAccessAsync(uow, userId, projectId, ct) — shared across the handlers below.

  • Produces: GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId) : IRequest<List<TaskDto>>, GetTaskQuery(Guid UserId, Guid TaskId) : IRequest<TaskDto>, SearchTasksQuery(Guid UserId, string Term) : IRequest<List<TaskDto>>, CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>, UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>, DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest.

  • Step 1: Create the shared task-access helper

Create mws.application/Tasks/TaskAccess.cs:

using AutoMapper;
using Mws.Application.Common;
using Mws.Domain.Tasks;

namespace Mws.Application.Tasks;

internal static class TaskAccess
{
    public static async Task<TaskDto> GetDtoAsync(IUnitOfWork uow, IMapper mapper, Guid id, CancellationToken ct)
    {
        var task = await uow.Tasks.GetWithAssigneeAsync(id, ct)
            ?? throw new NotFoundException("Task not found");
        return mapper.Map<TaskDto>(task);
    }

    public static async Task<TaskItem> GetTaskForUserAsync(IUnitOfWork uow, Guid userId, Guid taskId, CancellationToken ct)
    {
        var task = await uow.Tasks.GetByIdAsync(taskId, ct)
            ?? throw new NotFoundException("Task not found");

        if (!await uow.Projects.IsMemberAsync(task.ProjectId, userId, ct))
        {
            throw new NotFoundException("Task not found");
        }
        return task;
    }

    public static async Task EnsureMemberAccessAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct)
    {
        if (!await uow.Projects.IsMemberAsync(projectId, userId, ct))
        {
            throw new NotFoundException("Project not found");
        }
    }
}
  • Step 2: Create the queries

Create mws.application/Tasks/Queries/GetTasks.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;

namespace Mws.Application.Tasks;

public record GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId)
    : IRequest<List<TaskDto>>;

public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetTasksQuery, List<TaskDto>>
{
    public async Task<List<TaskDto>> Handle(GetTasksQuery query, CancellationToken ct)
    {
        await TaskAccess.EnsureMemberAccessAsync(uow, query.UserId, query.ProjectId, ct);

        var tasks = await uow.Tasks.GetForProjectAsync(query.ProjectId, query.Status, query.Priority, query.AssigneeId, ct);
        return mapper.Map<List<TaskDto>>(tasks);
    }
}

Create mws.application/Tasks/Queries/GetTask.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Tasks;

public record GetTaskQuery(Guid UserId, Guid TaskId) : IRequest<TaskDto>;

public class GetTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetTaskQuery, TaskDto>
{
    public async Task<TaskDto> Handle(GetTaskQuery query, CancellationToken ct)
    {
        var task = await TaskAccess.GetTaskForUserAsync(uow, query.UserId, query.TaskId, ct);
        return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct);
    }
}

Create mws.application/Tasks/Queries/SearchTasks.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Tasks;

public record SearchTasksQuery(Guid UserId, string Term) : IRequest<List<TaskDto>>;

public class SearchTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<SearchTasksQuery, List<TaskDto>>
{
    public async Task<List<TaskDto>> Handle(SearchTasksQuery query, CancellationToken ct)
    {
        var projectIds = await uow.Projects.GetProjectIdsForUserAsync(query.UserId, ct);
        var tasks = await uow.Tasks.SearchWithAssigneeAsync(projectIds, query.Term, 20, ct);
        return mapper.Map<List<TaskDto>>(tasks);
    }
}
  • Step 3: Create the CreateTask command

Create mws.application/Tasks/Commands/CreateTask.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;
using Mws.Domain.Tasks;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;

namespace Mws.Application.Tasks;

public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>;

public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateTaskCommand, TaskDto>
{
    public async Task<TaskDto> Handle(CreateTaskCommand command, CancellationToken ct)
    {
        await TaskAccess.EnsureMemberAccessAsync(uow, command.UserId, command.ProjectId, ct);

        var request = command.Request;
        if (string.IsNullOrWhiteSpace(request.Title))
        {
            throw new BadRequestException("Task title is required");
        }

        if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(command.ProjectId, request.AssigneeId.Value, ct))
        {
            throw new BadRequestException("Assignee must be a member of the project");
        }

        var now = DateTime.UtcNow;
        var task = new TaskItem
        {
            Id = Guid.NewGuid(),
            ProjectId = command.ProjectId,
            Title = request.Title.Trim(),
            Description = request.Description,
            Status = request.Status ?? TaskStatus.Todo,
            Priority = request.Priority ?? TaskPriority.Medium,
            AssigneeId = request.AssigneeId,
            DueDate = request.DueDate,
            CreatedBy = command.UserId,
            CreatedAt = now,
            UpdatedAt = now,
        };

        uow.Tasks.Add(task);
        await uow.SaveChangesAsync(ct);
        return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct);
    }
}
  • Step 4: Create the UpdateTask command

Create mws.application/Tasks/Commands/UpdateTask.cs:

using AutoMapper;
using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Tasks;

public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>;

public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateTaskCommand, TaskDto>
{
    public async Task<TaskDto> Handle(UpdateTaskCommand command, CancellationToken ct)
    {
        var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct);

        var request = command.Request;
        if (string.IsNullOrWhiteSpace(request.Title))
        {
            throw new BadRequestException("Task title is required");
        }

        if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(task.ProjectId, request.AssigneeId.Value, ct))
        {
            throw new BadRequestException("Assignee must be a member of the project");
        }

        task.Title = request.Title.Trim();
        task.Description = request.Description;
        task.Status = request.Status;
        task.Priority = request.Priority;
        task.AssigneeId = request.AssigneeId;
        task.DueDate = request.DueDate;
        task.UpdatedAt = DateTime.UtcNow;

        await uow.SaveChangesAsync(ct);
        return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct);
    }
}
  • Step 5: Create the DeleteTask command

Create mws.application/Tasks/Commands/DeleteTask.cs:

using MediatR;
using Mws.Application.Common;

namespace Mws.Application.Tasks;

public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest;

public class DeleteTaskHandler(IUnitOfWork uow) : IRequestHandler<DeleteTaskCommand>
{
    public async Task Handle(DeleteTaskCommand command, CancellationToken ct)
    {
        var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct);
        uow.Tasks.Remove(task);
        await uow.SaveChangesAsync(ct);
    }
}
  • Step 6: Delete the old service files

Delete mws.application/Tasks/ITaskService.cs and mws.application/Tasks/TaskService.cs.

  • Step 7: Update TasksController

Replace the full contents of mws.api/Controllers/TasksController.cs:

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mws.Application.Common;
using Mws.Application.Tasks;
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
using TaskStatus = Mws.Domain.Tasks.TaskStatus;

namespace Mws.Api.Controllers;

[ApiController]
[Route("api")]
[Authorize]
public class TasksController(ISender sender) : ControllerBase
{
    [HttpGet("tasks/search")]
    public async Task<ActionResult<List<TaskDto>>> Search([FromQuery] string? q, CancellationToken ct)
    {
        return Ok(await sender.Send(new SearchTasksQuery(User.GetUserId(), q ?? string.Empty), ct));
    }

    [HttpGet("projects/{projectId:guid}/tasks")]
    public async Task<ActionResult<List<TaskDto>>> GetAll(
        Guid projectId,
        [FromQuery] string? status,
        [FromQuery] string? priority,
        [FromQuery] Guid? assigneeId,
        CancellationToken ct)
    {
        var statusValue = ParseOptional<TaskStatus>(status);
        var priorityValue = ParseOptional<TaskPriority>(priority);
        return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId), ct));
    }

    [HttpPost("projects/{projectId:guid}/tasks")]
    public async Task<ActionResult<TaskDto>> Create(Guid projectId, [FromBody] CreateTaskRequest request, CancellationToken ct)
    {
        var result = await sender.Send(new CreateTaskCommand(User.GetUserId(), projectId, request), ct);
        return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
    }

    [HttpGet("tasks/{id:guid}")]
    public async Task<ActionResult<TaskDto>> Get(Guid id, CancellationToken ct)
    {
        return Ok(await sender.Send(new GetTaskQuery(User.GetUserId(), id), ct));
    }

    [HttpPut("tasks/{id:guid}")]
    public async Task<ActionResult<TaskDto>> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
    {
        return Ok(await sender.Send(new UpdateTaskCommand(User.GetUserId(), id, request), ct));
    }

    [HttpDelete("tasks/{id:guid}")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        await sender.Send(new DeleteTaskCommand(User.GetUserId(), id), ct);
        return NoContent();
    }

    private static TEnum? ParseOptional<TEnum>(string? value) where TEnum : struct, Enum
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return null;
        }
        return Enum.TryParse<TEnum>(value, ignoreCase: true, out var result) ? result : throw new BadRequestException($"Invalid {typeof(TEnum).Name}: {value}");
    }
}
  • Step 8: Remove the old DI registration

In mws.infrastructure/DependencyInjection.cs, remove the line services.AddScoped<ITaskService, TaskService>();.

  • Step 9: Build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s).

  • Step 10: Commit
git add mws.application/Tasks mws.api/Controllers/TasksController.cs mws.infrastructure/DependencyInjection.cs
git commit -m "$(cat <<'EOF'
Convert Tasks module to MediatR commands/queries

Replaces ITaskService/TaskService, completing the CQRS/mediator
refactor. All 9 old service interfaces are now gone except
IPermissionService, ITokenService, and IPasswordHasher, which stay
plain injected services by design (see spec).
EOF
)"

Task 8: Final verification

Files: none (verification only).

Interfaces: none — this task exercises the full request pipeline end to end.

  • Step 1: Confirm no old service interfaces remain

Run: grep -rl "IAuthService\|IAccountService\|IRoleService\|IProjectService\|IProjectMemberService\|IDocumentService\|ITaskService" mws.application mws.api mws.infrastructure Expected: no output (empty match). If anything matches, it's a leftover reference from an earlier task — fix it before continuing.

  • Step 2: Full solution build

Run: dotnet build mws.backend.dotnet.sln Expected: Build succeeded, 0 Error(s) (existing 2 nullable warnings in Program.cs are pre-existing and unrelated — fine to see those, not new ones).

  • Step 3: Start the API

Run: dotnet run --project mws.api (in the background, or a separate terminal) — it auto-migrates and seeds on startup per CLAUDE.md. Wait for Now listening on: http://localhost:5xxx (check mws.api/Properties/launchSettings.json for the exact port).

  • Step 4: Smoke-test the command path — login

Run: curl -s -X POST http://localhost:<port>/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"password"}' Expected: JSON body with a token field and a user object — confirms LoginCommand/LoginHandler and the MediatR DI wiring both work.

  • Step 5: Smoke-test a query + a command that goes through IUnitOfWork — projects

Using the token from Step 4:

TOKEN="<paste token here>"
curl -s http://localhost:<port>/api/projects -H "Authorization: Bearer $TOKEN"
curl -s -X POST http://localhost:<port>/api/projects -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name":"CQRS smoke test"}'

Expected: first call returns the seeded demo project as a JSON array; second call returns 200 with the new project's DTO — confirms GetProjectsQuery and CreateProjectCommand both round-trip through EF Core correctly.

  • Step 6: Smoke-test the untouched permission path — menu

Run: curl -s http://localhost:<port>/api/menu -H "Authorization: Bearer $TOKEN" Expected: JSON array of menu items with CRUD flags — confirms IPermissionService (left as a plain injected service) still resolves and works alongside the new MediatR-based handlers.

  • Step 7: Stop the API

Stop the dotnet run process (Ctrl-C, or kill the background job).

No commit for this task — it's verification only, nothing changed.