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.
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 show0 Error(s)) plus a manual curl smoke test in the final task. - Solution file is
mws.backend.dotnet.slnat repo root; actual project folders are lowercase (mws.api/,mws.application/,mws.domain/,mws.infrastructure/) despiteCLAUDE.mdreferring to PascalCase paths — use the lowercase paths, they are what's on disk. IPermissionService,ITokenService,IPasswordHasherregistrations and implementations are NOT touched by this plan.- AutoMapper (
IMapper) usage is unchanged — samemapper.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(addAddMediatR, removeAddScoped<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, fromAuth/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(removeAddScoped<IAccountService, AccountService>)
Interfaces:
-
Consumes:
IUnitOfWork,IPasswordHasher(Mws.Application.Auth),IPermissionService/PermissionAction(Mws.Application.Permissions) — all unchanged.AccountDto,CreateAccountRequest,UpdateAccountRequest,ResetPasswordRequestfromAccounts/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(removeAddScoped<IRoleService, RoleService>)
Interfaces:
-
Produces:
RolePermissionBuilder.Build(List<PermissionEntryDto>) : List<RolePermission>— shared byCreateRoleHandlerandUpdateRoleHandler, replacing the oldRoleService.BuildPermissionsprivate 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.EnsureAsyncstays called directly fromRolesController, unchanged — the permission check is NOT moved into the handlers (it wasn't in the oldRoleServiceeither; 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(removeIProjectService, keepIProjectMemberService— Task 5 removes it) - Delete:
mws.application/Projects/ProjectService.cs - Modify:
mws.api/Controllers/ProjectsController.cs - Modify:
mws.infrastructure/DependencyInjection.cs(removeAddScoped<IProjectService, ProjectService>)
Interfaces:
-
Produces:
ProjectAccess.GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) : Task<Project>— shared byGetProjectHandler,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 inIProjectService.csfor this task — do not delete it yet,ProjectMembersControllerstill 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
IProjectServicefromIProjectService.cs, keepIProjectMemberService
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 onlyIProjectMemberService, no longer needed) - Delete:
mws.application/Projects/ProjectMemberService.cs - Modify:
mws.api/Controllers/ProjectMembersController.cs - Modify:
mws.infrastructure/DependencyInjection.cs(removeAddScoped<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(removeAddScoped<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(removeAddScoped<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.