# 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` 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(...)` 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`) - 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` — 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; public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper) : IRequestHandler { public async Task 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(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> 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();`. 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`) **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>`, `CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest`, `UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest`, `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>; public class GetAccountsHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) : IRequestHandler> { private const string Screen = "accounts"; public async Task> 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>(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; public class CreateAccountHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) : IRequestHandler { private const string Screen = "accounts"; public async Task 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(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; public class UpdateAccountHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) : IRequestHandler { private const string Screen = "accounts"; public async Task 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(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 { 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 { 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>> GetAll([FromQuery] string? q, CancellationToken ct) { return Ok(await sender.Send(new GetAccountsQuery(User.GetUserId(), q), ct)); } [HttpPost] public async Task> Create([FromBody] CreateAccountRequest request, CancellationToken ct) { return Ok(await sender.Send(new CreateAccountCommand(User.GetUserId(), request), ct)); } [HttpPut("{id:guid}")] public async Task> 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 Delete(Guid id, CancellationToken ct) { await sender.Send(new DeleteAccountCommand(User.GetUserId(), id), ct); return NoContent(); } [HttpPost("{id:guid}/reset-password")] public async Task 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();`. - [ ] **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`) **Interfaces:** - Produces: `RolePermissionBuilder.Build(List) : List` — shared by `CreateRoleHandler` and `UpdateRoleHandler`, replacing the old `RoleService.BuildPermissions` private method. - Produces: `GetRolesQuery : IRequest>`, `GetRoleQuery(Guid Id) : IRequest`, `CreateRoleCommand(SaveRoleRequest Request) : IRequest`, `UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest`, `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 Build(List 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>; public class GetRolesHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> Handle(GetRolesQuery query, CancellationToken ct) { var roles = await uow.Roles.GetAllWithPermissionsAsync(ct); return mapper.Map>(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; public class GetRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task Handle(GetRoleQuery query, CancellationToken ct) { var role = await uow.Roles.GetByIdWithPermissionsAsync(query.Id, ct) ?? throw new NotFoundException("Role not found"); return mapper.Map(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; public class CreateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(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; public class UpdateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(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 { 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>> 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> 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> 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> 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 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();`. - [ ] **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`) **Interfaces:** - Produces: `ProjectAccess.GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) : Task` — shared by `GetProjectHandler`, `UpdateProjectHandler`, `ArchiveProjectHandler`, `GetProjectOverviewHandler`. - Produces: `GetProjectsQuery(Guid UserId) : IRequest>`, `GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest`, `SearchProjectsQuery(Guid UserId, string Term) : IRequest>`, `GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest`, `CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest`, `UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest`, `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 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>; public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> Handle(GetProjectsQuery query, CancellationToken ct) { var projects = await uow.Projects.GetForUserAsync(query.UserId, ct); return mapper.Map>(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; public class GetProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task Handle(GetProjectQuery query, CancellationToken ct) { var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct); return mapper.Map(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>; public class SearchProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> Handle(SearchProjectsQuery query, CancellationToken ct) { var projects = await uow.Projects.SearchForUserAsync(query.UserId, query.Term, ct); return mapper.Map>(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; public class GetProjectOverviewHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(project), MemberCount = memberCount, DocumentCount = documentCount, TaskCountsByStatus = taskCountsByStatus, RecentTasks = mapper.Map>(recentTasks), RecentDocuments = mapper.Map>(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; public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(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; public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(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 { 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> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default); Task AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default); Task 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>> GetAll(CancellationToken ct) { return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId()), ct)); } [HttpGet("search")] public async Task>> 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> Get(Guid projectId, CancellationToken ct) { return Ok(await sender.Send(new GetProjectQuery(User.GetUserId(), projectId), ct)); } [HttpGet("{projectId:guid}/overview")] public async Task> Overview(Guid projectId, CancellationToken ct) { return Ok(await sender.Send(new GetProjectOverviewQuery(User.GetUserId(), projectId), ct)); } [HttpPost] public async Task> 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> 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 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();`. Leave `services.AddScoped();` 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`) **Interfaces:** - Produces: `GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest>`, `AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest`, `UpdateMemberDocumentPermissionsCommand(Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest`, `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>; public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler> { public async Task> 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; public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler { public async Task 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; public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow) : IRequestHandler { public async Task 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 { 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>> GetAll(Guid projectId, CancellationToken ct) { return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId), ct)); } [HttpPost] public async Task> 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> 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 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();`. - [ ] **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`) **Interfaces:** - Produces: `DocumentAccess.EnsureDocumentPermissionAsync(uow, userId, projectId, action, ct)`, `DocumentAccess.HasDocumentPermissionAsync(uow, member, action, ct) : Task`, `DocumentAccess.GetDocumentForUserAsync(uow, userId, documentId, ct) : Task`, `DocumentAccess.DeleteDescendantsAsync(uow, parentId, ct)` — shared across every Documents handler below. - Produces: `GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest>`, `GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest`, `SearchDocumentsQuery(Guid UserId, string Term) : IRequest>`, `CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest`, `UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest`, `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 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 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>; public class GetDocumentTreeHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> 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(d)); var roots = new List(); 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; public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task Handle(GetDocumentQuery query, CancellationToken ct) { var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct); return mapper.Map(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>; public class SearchDocumentsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> 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>(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; public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(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; public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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(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 { 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 { 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>> 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>> GetTree(Guid projectId, CancellationToken ct) { return Ok(await sender.Send(new GetDocumentTreeQuery(User.GetUserId(), projectId), ct)); } [HttpPost("projects/{projectId:guid}/documents")] public async Task> 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> Get(Guid id, CancellationToken ct) { return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct)); } [HttpPut("documents/{id:guid}")] public async Task> 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> 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 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();`. - [ ] **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`) **Interfaces:** - Produces: `TaskAccess.GetDtoAsync(uow, mapper, id, ct) : Task`, `TaskAccess.GetTaskForUserAsync(uow, userId, taskId, ct) : Task`, `TaskAccess.EnsureMemberAccessAsync(uow, userId, projectId, ct)` — shared across the handlers below. - Produces: `GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId) : IRequest>`, `GetTaskQuery(Guid UserId, Guid TaskId) : IRequest`, `SearchTasksQuery(Guid UserId, string Term) : IRequest>`, `CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest`, `UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest`, `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 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(task); } public static async Task 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>; public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> 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>(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; public class GetTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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>; public class SearchTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> 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>(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; public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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; public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { public async Task 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 { 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>> 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>> GetAll( Guid projectId, [FromQuery] string? status, [FromQuery] string? priority, [FromQuery] Guid? assigneeId, CancellationToken ct) { var statusValue = ParseOptional(status); var priorityValue = ParseOptional(priority); return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId), ct)); } [HttpPost("projects/{projectId:guid}/tasks")] public async Task> 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> Get(Guid id, CancellationToken ct) { return Ok(await sender.Send(new GetTaskQuery(User.GetUserId(), id), ct)); } [HttpPut("tasks/{id:guid}")] public async Task> 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 Delete(Guid id, CancellationToken ct) { await sender.Send(new DeleteTaskCommand(User.GetUserId(), id), ct); return NoContent(); } private static TEnum? ParseOptional(string? value) where TEnum : struct, Enum { if (string.IsNullOrWhiteSpace(value)) { return null; } return Enum.TryParse(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();`. - [ ] **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:/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="" curl -s http://localhost:/api/projects -H "Authorization: Bearer $TOKEN" curl -s -X POST http://localhost:/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:/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.