From ed5146c73bea405043b7cce9c54d3c650ea43342 Mon Sep 17 00:00:00 2001 From: namdh Date: Sun, 13 Sep 2026 18:02:09 +0700 Subject: [PATCH] feat: add ci --- .gitlab-ci.yml | 58 + api/Controllers/MasterDataController.cs | 6 +- api/Controllers/ProjectMembersController.cs | 7 +- api/Controllers/ProjectsController.cs | 9 +- api/Controllers/UsersController.cs | 6 +- api/Properties/launchSettings.json | 4 +- .../Repositories/IMasterDataRepository.cs | 3 +- .../Common/Repositories/IProjectRepository.cs | 5 +- .../Common/Repositories/IUserRepository.cs | 3 +- application/MasterData/Contracts.cs | 2 + .../MasterData/Queries/GetMasterDataList.cs | 4 +- application/Projects/Contracts.cs | 6 +- .../Projects/Queries/GetProjectMembers.cs | 4 +- application/Projects/Queries/GetProjects.cs | 4 +- application/Users/Contracts.cs | 4 +- application/Users/Queries/GetUsers.cs | 4 +- .../plans/2026-08-13-cqrs-mediator.md | 2319 ----------------- .../specs/2026-08-13-cqrs-mediator-design.md | 120 - .../Repositories/MasterDataRepository.cs | 26 +- .../Repositories/ProjectRepository.cs | 57 +- .../Repositories/UserRepository.cs | 12 +- 21 files changed, 186 insertions(+), 2477 deletions(-) create mode 100644 .gitlab-ci.yml delete mode 100644 docs/superpowers/plans/2026-08-13-cqrs-mediator.md delete mode 100644 docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..bdc2231 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,58 @@ +stages: + - build + - push + - deploy + +variables: + IMAGE_SHA: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" + IMAGE_LATEST: "$CI_REGISTRY_IMAGE:latest" + CONTAINER_NAME: "mws-api" + HOST_PORT: "2000" + CONTAINER_PORT: "8080" + DEPLOY_SSH_PORT: "8686" + ASPNETCORE_ENVIRONMENT: "Production" + +default: + tags: + - deploy + - build + +workflow: + rules: + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"' + - when: never + +build: + stage: build + script: + - echo "Build image $IMAGE_SHA và $IMAGE_LATEST" + - docker build -t "$IMAGE_SHA" -t "$IMAGE_LATEST" . + +push: + stage: push + script: + - echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$CI_REGISTRY" + - docker push "$IMAGE_SHA" + - docker push "$IMAGE_LATEST" + - docker logout "$CI_REGISTRY" + +deploy: + stage: deploy + environment: + name: production + before_script: + - command -v ssh-agent >/dev/null 2>&1 || (apt-get update -y && apt-get install -y openssh-client) + - eval "$(ssh-agent -s)" + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - ssh-keyscan -p "$DEPLOY_SSH_PORT" -H "$DEPLOYER_HOST" >> ~/.ssh/known_hosts 2>/dev/null + - chmod 644 ~/.ssh/known_hosts + script: + - ENV_FILE="/home/$DEPLOYER_USER/apps/$CI_PROJECT_NAME/.env" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "echo '$REGISTRY_PASSWORD' | docker login -u '$REGISTRY_USER' --password-stdin '$CI_REGISTRY'" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker pull '$IMAGE_SHA'" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker stop '$CONTAINER_NAME' 2>/dev/null || true" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker rm '$CONTAINER_NAME' 2>/dev/null || true" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "test -f '$ENV_FILE' || { echo \"Thiếu $ENV_FILE trên server\"; exit 1; }" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker run -d --name '$CONTAINER_NAME' --restart unless-stopped --env-file '$ENV_FILE' -e ASPNETCORE_ENVIRONMENT='$ASPNETCORE_ENVIRONMENT' -p '$HOST_PORT:$CONTAINER_PORT' '$IMAGE_SHA'" + - ssh -p "$DEPLOY_SSH_PORT" "$DEPLOYER_USER@$DEPLOYER_HOST" "docker logout '$CI_REGISTRY'" diff --git a/api/Controllers/MasterDataController.cs b/api/Controllers/MasterDataController.cs index efddca7..4f5b846 100644 --- a/api/Controllers/MasterDataController.cs +++ b/api/Controllers/MasterDataController.cs @@ -20,10 +20,12 @@ public class MasterDataController(ISender sender, IPermissionService permissions [HttpGet] public async Task>> GetAll( - [FromQuery] string? group, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) + [FromQuery] string? group, [FromQuery] string? q, [FromQuery] bool? isActive, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) { await permissions.EnsureAsync(User.GetUserId(), "masterdata", PermissionAction.View, ct); - return Ok(await sender.Send(new GetMasterDataListQuery(group, page, pageSize), ct)); + var filter = new MasterDataFilter(group, q, isActive); + return Ok(await sender.Send(new GetMasterDataListQuery(filter, page, pageSize), ct)); } [HttpPost] diff --git a/api/Controllers/ProjectMembersController.cs b/api/Controllers/ProjectMembersController.cs index a0d0478..9070aa7 100644 --- a/api/Controllers/ProjectMembersController.cs +++ b/api/Controllers/ProjectMembersController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Projects; +using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.api.Controllers; @@ -13,9 +14,11 @@ public class ProjectMembersController(ISender sender) : ControllerBase { [HttpGet] public async Task>> GetAll( - Guid projectId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) + Guid projectId, [FromQuery] string? q, [FromQuery] MemberRole? role, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) { - return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId, page, pageSize), ct)); + var filter = new MemberListFilter(q, role); + return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId, filter, page, pageSize), ct)); } [HttpPost] diff --git a/api/Controllers/ProjectsController.cs b/api/Controllers/ProjectsController.cs index 72642f2..6c05c0b 100644 --- a/api/Controllers/ProjectsController.cs +++ b/api/Controllers/ProjectsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Projects; +using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.api.Controllers; @@ -12,9 +13,13 @@ namespace mws.backend.dotnet.api.Controllers; public class ProjectsController(ISender sender) : ControllerBase { [HttpGet] - public async Task>> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) + public async Task>> GetAll( + [FromQuery] string? q, [FromQuery] ProjectStatus? status, + [FromQuery] string? createdBy, [FromQuery] string? updatedBy, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) { - return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId(), page, pageSize), ct)); + var filter = new ProjectListFilter(User.GetUserId(), q, status, createdBy, updatedBy); + return Ok(await sender.Send(new GetProjectsQuery(filter, page, pageSize), ct)); } [HttpGet("search")] diff --git a/api/Controllers/UsersController.cs b/api/Controllers/UsersController.cs index e17c247..8e103ef 100644 --- a/api/Controllers/UsersController.cs +++ b/api/Controllers/UsersController.cs @@ -17,9 +17,11 @@ namespace mws.backend.dotnet.api.Controllers; public class UsersController(ISender sender, AppDbContext db, IPermissionService permissions, IMapper mapper) : ControllerBase { [HttpGet] - public async Task>> GetAll([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) + public async Task>> GetAll( + [FromQuery] string? q, [FromQuery] bool? isActive, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) { - return Ok(await sender.Send(new GetUsersQuery(User.GetUserId(), q, page, pageSize), ct)); + var filter = new UserListFilter(q, isActive); + return Ok(await sender.Send(new GetUsersQuery(User.GetUserId(), filter, page, pageSize), ct)); } [HttpPost] diff --git a/api/Properties/launchSettings.json b/api/Properties/launchSettings.json index 3c9b724..100412e 100644 --- a/api/Properties/launchSettings.json +++ b/api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:2000", + "applicationUrl": "http://localhost:2001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:3000;http://localhost:2000", + "applicationUrl": "https://localhost:3000;http://localhost:2001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/application/Common/Repositories/IMasterDataRepository.cs b/application/Common/Repositories/IMasterDataRepository.cs index 671548b..6381a96 100644 --- a/application/Common/Repositories/IMasterDataRepository.cs +++ b/application/Common/Repositories/IMasterDataRepository.cs @@ -1,11 +1,12 @@ using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.MasterData; using mws.backend.dotnet.domain.MasterData; namespace mws.backend.dotnet.application.Common.Repositories; public interface IMasterDataRepository : IRepository { - Task> GetAllAsync(string? group, int page, int pageSize, CancellationToken ct = default); + Task> GetAllAsync(MasterDataFilter filter, int page, int pageSize, CancellationToken ct = default); Task> GetActiveByGroupAsync(string group, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); Task ExistsAsync(string group, string value, Guid? excludeId, CancellationToken ct = default); diff --git a/application/Common/Repositories/IProjectRepository.cs b/application/Common/Repositories/IProjectRepository.cs index ad986a6..dc88b13 100644 --- a/application/Common/Repositories/IProjectRepository.cs +++ b/application/Common/Repositories/IProjectRepository.cs @@ -1,4 +1,5 @@ using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Projects; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Common.Repositories; @@ -7,7 +8,7 @@ public interface IProjectRepository : IRepository { Task GetByIdAsync(Guid id, CancellationToken ct = default); Task GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default); - Task> GetForUserAsync(Guid userId, int page, int pageSize, CancellationToken ct = default); + Task> GetForUserAsync(ProjectListFilter filter, int page, int pageSize, CancellationToken ct = default); Task> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default); Task CountMembersAsync(Guid projectId, CancellationToken ct = default); @@ -15,7 +16,7 @@ public interface IProjectRepository : IRepository Task GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default); Task GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default); Task GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default); - Task> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default); + Task> GetMembersWithUserAsync(Guid projectId, MemberListFilter filter, int page, int pageSize, CancellationToken ct = default); Task CountOwnersAsync(Guid projectId, CancellationToken ct = default); Task> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default); Task> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default); diff --git a/application/Common/Repositories/IUserRepository.cs b/application/Common/Repositories/IUserRepository.cs index 2f1e0fc..f888ce2 100644 --- a/application/Common/Repositories/IUserRepository.cs +++ b/application/Common/Repositories/IUserRepository.cs @@ -1,4 +1,5 @@ using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Users; using mws.backend.dotnet.domain.Users; namespace mws.backend.dotnet.application.Common.Repositories; @@ -12,5 +13,5 @@ public interface IUserRepository : IRepository Task ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default); Task> GetRoleIdsAsync(Guid userId, CancellationToken ct = default); Task> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default); - Task> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default); + Task> SearchWithRolePagedAsync(UserListFilter filter, int page, int pageSize, CancellationToken ct = default); } diff --git a/application/MasterData/Contracts.cs b/application/MasterData/Contracts.cs index 65fe527..3b84b68 100644 --- a/application/MasterData/Contracts.cs +++ b/application/MasterData/Contracts.cs @@ -18,3 +18,5 @@ public class SaveMasterDataRequest public int SortOrder { get; set; } public bool IsActive { get; set; } = true; } + +public record MasterDataFilter(string? Group, string? Term, bool? IsActive); diff --git a/application/MasterData/Queries/GetMasterDataList.cs b/application/MasterData/Queries/GetMasterDataList.cs index 296db8c..820f006 100644 --- a/application/MasterData/Queries/GetMasterDataList.cs +++ b/application/MasterData/Queries/GetMasterDataList.cs @@ -4,13 +4,13 @@ using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.MasterData; -public record GetMasterDataListQuery(string? Group, int Page, int PageSize) : IRequest>; +public record GetMasterDataListQuery(MasterDataFilter Filter, int Page, int PageSize) : IRequest>; public class GetMasterDataListHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> { public async Task> Handle(GetMasterDataListQuery query, CancellationToken ct) { - var entries = await uow.MasterData.GetAllAsync(query.Group, query.Page, query.PageSize, ct); + var entries = await uow.MasterData.GetAllAsync(query.Filter, query.Page, query.PageSize, ct); return new PagedResult { Items = mapper.Map>(entries.Items), diff --git a/application/Projects/Contracts.cs b/application/Projects/Contracts.cs index 4343155..61d1338 100644 --- a/application/Projects/Contracts.cs +++ b/application/Projects/Contracts.cs @@ -78,4 +78,8 @@ public class RecentDocumentDto public Guid Id { get; set; } public string Title { get; set; } = string.Empty; public DateTime UpdatedAt { get; set; } -} \ No newline at end of file +} + +public record ProjectListFilter(Guid UserId, string? Term, ProjectStatus? Status, string? CreatedBy, string? UpdatedBy); + +public record MemberListFilter(string? Term, MemberRole? Role); \ No newline at end of file diff --git a/application/Projects/Queries/GetProjectMembers.cs b/application/Projects/Queries/GetProjectMembers.cs index d4883ec..65ae37a 100644 --- a/application/Projects/Queries/GetProjectMembers.cs +++ b/application/Projects/Queries/GetProjectMembers.cs @@ -4,7 +4,7 @@ using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; -public record GetProjectMembersQuery(Guid UserId, Guid ProjectId, int Page, int PageSize) : IRequest>; +public record GetProjectMembersQuery(Guid UserId, Guid ProjectId, MemberListFilter Filter, int Page, int PageSize) : IRequest>; public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler> { @@ -16,7 +16,7 @@ public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler diff --git a/application/Projects/Queries/GetProjects.cs b/application/Projects/Queries/GetProjects.cs index 1a572b2..8d8f1d6 100644 --- a/application/Projects/Queries/GetProjects.cs +++ b/application/Projects/Queries/GetProjects.cs @@ -4,13 +4,13 @@ using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.Projects; -public record GetProjectsQuery(Guid UserId, int Page, int PageSize) : IRequest>; +public record GetProjectsQuery(ProjectListFilter Filter, int Page, int PageSize) : 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, query.Page, query.PageSize, ct); + var projects = await uow.Projects.GetForUserAsync(query.Filter, query.Page, query.PageSize, ct); return new PagedResult { Items = mapper.Map>(projects.Items), diff --git a/application/Users/Contracts.cs b/application/Users/Contracts.cs index 6ad29f6..cacc0cf 100644 --- a/application/Users/Contracts.cs +++ b/application/Users/Contracts.cs @@ -45,4 +45,6 @@ public class UserRoleDetailDto public class AssignRoleRequest { public Guid RoleId { get; set; } -} \ No newline at end of file +} + +public record UserListFilter(string? Term, bool? IsActive); \ No newline at end of file diff --git a/application/Users/Queries/GetUsers.cs b/application/Users/Queries/GetUsers.cs index 1c118f5..ff8cdfa 100644 --- a/application/Users/Queries/GetUsers.cs +++ b/application/Users/Queries/GetUsers.cs @@ -5,7 +5,7 @@ using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Users; -public record GetUsersQuery(Guid ActorUserId, string? Term, int Page, int PageSize) : IRequest>; +public record GetUsersQuery(Guid ActorUserId, UserListFilter Filter, int Page, int PageSize) : IRequest>; public class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) : IRequestHandler> @@ -16,7 +16,7 @@ public class GetUsersHandler(IUnitOfWork uow, IPermissionService permissions, IM { await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct); - var users = await uow.Users.SearchWithRolePagedAsync(query.Term, query.Page, query.PageSize, ct); + var users = await uow.Users.SearchWithRolePagedAsync(query.Filter, query.Page, query.PageSize, ct); return new PagedResult { Items = mapper.Map>(users.Items), diff --git a/docs/superpowers/plans/2026-08-13-cqrs-mediator.md b/docs/superpowers/plans/2026-08-13-cqrs-mediator.md deleted file mode 100644 index 78178ac..0000000 --- a/docs/superpowers/plans/2026-08-13-cqrs-mediator.md +++ /dev/null @@ -1,2319 +0,0 @@ -# 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. diff --git a/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md b/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md deleted file mode 100644 index c701ea9..0000000 --- a/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md +++ /dev/null @@ -1,120 +0,0 @@ -# CQRS/Mediator Refactor — Design - -## Goal - -Replace the `IXService`/`XService` application layer (9 interfaces, ~38 -methods, injected directly into controllers) with MediatR commands/queries -and handlers. Controllers depend on `ISender` only; each HTTP action sends -one request and returns the result. No behavior change — this is a -structural refactor, not a feature change. - -## Why - -Current pattern is service-per-aggregate with broad interfaces -(`IProjectService` has 7 methods covering reads, writes, search). CQRS -splits each operation into its own request type, so each handler has one -job, and cross-cutting concerns (validation, logging, auth) can later be -added as MediatR pipeline behaviors without touching every service class. - -## Package - -- Add `MediatR` (latest v13) to `mws.application.csproj`. -- Note: MediatR (Jimmy Bogard) moved to a commercial license for - non-OSS/non-trivial use starting v13, mirroring AutoMapper (already used - in this repo). Explicitly accepted by the project owner for this refactor; - AutoMapper is out of scope and left as-is. - -## Scope - -All 7 request-bearing service modules, converted in one pass (not piloted): - -| Module | Old interface | Methods | -|---|---|---| -| Auth | `IAuthService` | `LoginAsync` | -| Accounts | `IAccountService` | `GetAccountsAsync`, `CreateAccountAsync`, `UpdateAccountAsync`, `DeleteAccountAsync`, `ResetPasswordAsync` | -| Roles | `IRoleService` | `GetRolesAsync`, `GetRoleAsync`, `CreateRoleAsync`, `UpdateRoleAsync`, `DeleteRoleAsync` | -| Projects | `IProjectService` | `GetProjectsAsync`, `GetProjectAsync`, `CreateProjectAsync`, `UpdateProjectAsync`, `ArchiveProjectAsync`, `GetOverviewAsync`, `SearchAsync` | -| ProjectMembers | `IProjectMemberService` | `GetMembersAsync`, `AddMemberAsync`, `UpdateMemberDocumentPermissionsAsync`, `RemoveMemberAsync` | -| Documents | `IDocumentService` | `GetTreeAsync`, `GetAsync`, `CreateAsync`, `UpdateAsync`, `MoveAsync`, `DeleteAsync`, `SearchAsync` | -| Tasks | `ITaskService` | `GetTasksAsync`, `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `SearchAsync` | - -**Excluded from conversion** (stay plain injected services, unchanged): - -- `IPermissionService` (`EnsureAsync`, `GetMenuAsync`) — `EnsureAsync` is - called from inside `AccountService`'s methods (soon `Account*Handler`s) - and directly in `RolesController`. Converting it to a mediator request - would mean handlers calling `Send()` on other handlers, which MediatR's - own docs call out as an anti-pattern. `GetMenuAsync` stays alongside it - for consistency (one small interface, one caller: `MenuController`). -- `ITokenService`, `IPasswordHasher` — infrastructure helpers consumed by - handlers, not themselves endpoint operations. - -## Conventions - -Per module folder (e.g. `mws.application/Projects/`): - -- `Commands/` — one file per write operation: `CreateProject.cs` containing - `public record CreateProjectCommand(Guid UserId, string Name, string? - Description) : IRequest;` and - `public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : - IRequestHandler` directly below it in - the same file. Void operations (`Archive`, `Delete`, `Remove`) use - `IRequest` (no generic parameter) instead of `IRequest`. -- `Queries/` — same colocation pattern for reads: `GetProjects.cs`, - `SearchProjects.cs`, etc. -- `Contracts.cs` (DTOs) stays exactly as-is. -- Handler bodies are the old service method bodies moved verbatim — same - `IUnitOfWork` calls, same exceptions (`NotFoundException`, - `ForbiddenException`, `BadRequestException`), same authorization checks - (`GetMemberRoleAsync`, `EnsureMemberAccessAsync`, etc.). No logic changes. -- The `Guid userId`/`actorUserId` parameter that every old service method - took explicitly becomes the first property on the command/query record - (handlers have no ambient HTTP context). -- Delete `IXService.cs` and `XService.cs` for every converted module once - its controller no longer references the interface. - -## Controllers - -Each controller's constructor param changes from `IXService x` to -`ISender sender` (`MediatR`). Each action body changes from -`await x.SomeAsync(User.GetUserId(), ...)` to -`await sender.Send(new SomeCommand(User.GetUserId(), ...), ct)`. Routes, -HTTP verbs, status codes, and response types are unchanged. - -`RolesController` and `MenuController` keep their existing -`IPermissionService` dependency alongside the new `ISender`. - -## DI wiring - -`mws.infrastructure/DependencyInjection.cs`: -- Remove the 8 `services.AddScoped()` lines for the 7 - converted modules (Auth, Accounts, Roles, Projects, ProjectMembers, - Documents, Tasks). -- Add `services.AddMediatR(cfg => - cfg.RegisterServicesFromAssembly(typeof(Mws.Application.Common.IUnitOfWork).Assembly));` -- Keep `IPermissionService`, `ITokenService`, `IPasswordHasher` - registrations unchanged. - -## Error handling - -Unchanged. Handlers throw the same `Mws.Application.Common.Exceptions` -types the services did; `ApiExceptionMiddleware` still catches them at the -API boundary — nothing about that pipeline changes. - -## Testing / verification - -No test project exists in this repo (per `CLAUDE.md`, none expected today). -Verification is: -1. `dotnet build Mws.slnx` succeeds with no errors. -2. `dotnet run --project Mws.Api`, then smoke-test with curl: - login (Auth), list + create a project (Projects, command + query path), - get menu (confirms `IPermissionService` still wired and untouched). - -## Out of scope - -- AutoMapper stays as-is. -- No FluentValidation / MediatR pipeline behaviors — not requested, and the - ladder says don't add abstractions nobody asked for. Can be layered on - later since the request/handler shape is already in place. -- No repository/read-model split — queries still go through the existing - `IUnitOfWork` repositories. diff --git a/infrastructure/Persistence/Repositories/MasterDataRepository.cs b/infrastructure/Persistence/Repositories/MasterDataRepository.cs index f2cf748..584fdef 100644 --- a/infrastructure/Persistence/Repositories/MasterDataRepository.cs +++ b/infrastructure/Persistence/Repositories/MasterDataRepository.cs @@ -1,16 +1,36 @@ using Microsoft.EntityFrameworkCore; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.application.MasterData; using mws.backend.dotnet.domain.MasterData; namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; public class MasterDataRepository(AppDbContext db) : RepositoryBase(db), IMasterDataRepository { - public Task> GetAllAsync(string? group, int page, int pageSize, CancellationToken ct = default) => - Set.Where(m => group == null || m.Group == group) - .OrderBy(m => m.Group).ThenBy(m => m.SortOrder).ThenBy(m => m.Label) + public Task> GetAllAsync(MasterDataFilter filter, int page, int pageSize, CancellationToken ct = default) + { + var query = Set.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(filter.Group)) + { + query = query.Where(m => m.Group == filter.Group); + } + + if (!string.IsNullOrWhiteSpace(filter.Term)) + { + var lower = filter.Term.Trim().ToLower(); + query = query.Where(m => m.Label.ToLower().Contains(lower) || m.Value.ToLower().Contains(lower)); + } + + if (filter.IsActive is { } active) + { + query = query.Where(m => m.IsActive == active); + } + + return query.OrderBy(m => m.Group).ThenBy(m => m.SortOrder).ThenBy(m => m.Label) .ToPagedResultAsync(page, pageSize, ct); + } public Task> GetActiveByGroupAsync(string group, CancellationToken ct = default) => Set.Where(m => m.Group == group && m.IsActive) diff --git a/infrastructure/Persistence/Repositories/ProjectRepository.cs b/infrastructure/Persistence/Repositories/ProjectRepository.cs index 35573d3..5da4dee 100644 --- a/infrastructure/Persistence/Repositories/ProjectRepository.cs +++ b/infrastructure/Persistence/Repositories/ProjectRepository.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.application.Projects; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; @@ -14,11 +15,36 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase(db), I Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser) .FirstOrDefaultAsync(p => p.Id == projectId && p.Members.Any(m => m.UserId == userId), ct); - public Task> GetForUserAsync(Guid userId, int page, int pageSize, CancellationToken ct = default) => - Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser) - .Where(p => p.Members.Any(m => m.UserId == userId)) - .OrderByDescending(p => p.UpdatedAt) - .ToPagedResultAsync(page, pageSize, ct); + public Task> GetForUserAsync(ProjectListFilter filter, int page, int pageSize, CancellationToken ct = default) + { + var query = Set.Include(p => p.CreatedByUser).Include(p => p.UpdatedByUser) + .Where(p => p.Members.Any(m => m.UserId == filter.UserId)); + + if (!string.IsNullOrWhiteSpace(filter.Term)) + { + var lower = filter.Term.Trim().ToLower(); + query = query.Where(p => p.Name.ToLower().Contains(lower)); + } + + if (filter.Status is { } status) + { + query = query.Where(p => p.Status == status); + } + + if (!string.IsNullOrWhiteSpace(filter.CreatedBy)) + { + var lower = filter.CreatedBy.Trim().ToLower(); + query = query.Where(p => p.CreatedByUser != null && p.CreatedByUser.DisplayName.ToLower().Contains(lower)); + } + + if (!string.IsNullOrWhiteSpace(filter.UpdatedBy)) + { + var lower = filter.UpdatedBy.Trim().ToLower(); + query = query.Where(p => p.UpdatedByUser != null && p.UpdatedByUser.DisplayName.ToLower().Contains(lower)); + } + + return query.OrderByDescending(p => p.UpdatedAt).ToPagedResultAsync(page, pageSize, ct); + } public Task> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default) { @@ -51,13 +77,28 @@ public class ProjectRepository(AppDbContext db) : RepositoryBase(db), I public Task GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default) => Db.ProjectMembers.Include(m => m.User).FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct); - public Task> GetMembersWithUserAsync(Guid projectId, int page, int pageSize, CancellationToken ct = default) => - Db.ProjectMembers + public Task> GetMembersWithUserAsync(Guid projectId, MemberListFilter filter, int page, int pageSize, CancellationToken ct = default) + { + var query = Db.ProjectMembers .Where(m => m.ProjectId == projectId) .Include(m => m.User) - .OrderByDescending(m => m.Role) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(filter.Term)) + { + var lower = filter.Term.Trim().ToLower(); + query = query.Where(m => m.User.Username.ToLower().Contains(lower) || m.User.DisplayName.ToLower().Contains(lower)); + } + + if (filter.Role is { } role) + { + query = query.Where(m => m.Role == role); + } + + return query.OrderByDescending(m => m.Role) .ThenBy(m => m.User.DisplayName) .ToPagedResultAsync(page, pageSize, ct); + } public Task CountOwnersAsync(Guid projectId, CancellationToken ct = default) => Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct); diff --git a/infrastructure/Persistence/Repositories/UserRepository.cs b/infrastructure/Persistence/Repositories/UserRepository.cs index bd64130..5f3f70b 100644 --- a/infrastructure/Persistence/Repositories/UserRepository.cs +++ b/infrastructure/Persistence/Repositories/UserRepository.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.application.Users; using mws.backend.dotnet.domain.Users; namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; @@ -43,15 +44,20 @@ public class UserRepository(AppDbContext db) : RepositoryBase(db), IUserRe return query.ToListAsync(ct); } - public Task> SearchWithRolePagedAsync(string? term, int page, int pageSize, CancellationToken ct = default) + public Task> SearchWithRolePagedAsync(UserListFilter filter, int page, int pageSize, CancellationToken ct = default) { var query = Set.Include(u => u.UserRoles).ThenInclude(ur => ur.Role).AsQueryable(); - if (!string.IsNullOrWhiteSpace(term)) + if (!string.IsNullOrWhiteSpace(filter.Term)) { - var lower = term.Trim().ToLower(); + var lower = filter.Term.Trim().ToLower(); query = query.Where(u => u.Username.ToLower().Contains(lower) || u.DisplayName.ToLower().Contains(lower)); } + if (filter.IsActive is { } active) + { + query = query.Where(u => u.IsActive == active); + } + return query.OrderBy(u => u.DisplayName).ToPagedResultAsync(page, pageSize, ct); } }