Files
mws.backend.dotnet/docs/superpowers/plans/2026-08-13-cqrs-mediator.md
T

2320 lines
83 KiB
Markdown
Raw Normal View History

2026-08-13 23:10:22 +07:00
# 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](../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`:
```csharp
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`:
```csharp
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(...)`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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):
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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`:
```csharp
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**
```bash
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:
```bash
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.