2320 lines
83 KiB
Markdown
2320 lines
83 KiB
Markdown
# CQRS/Mediator Refactor Implementation Plan
|
|||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Replace the 9 `IXService`/`XService` application-layer classes with MediatR commands/queries + handlers, one per operation, so controllers depend only on `ISender`.
|
||
|
|
|
||
|
|
**Architecture:** Each old service method becomes a `record XCommand`/`XQuery : IRequest<TResponse>` plus an `XHandler : IRequestHandler<...>` colocated in one file under a `Commands/` or `Queries/` folder per module. Handler bodies are the old method bodies moved verbatim. `IPermissionService`, `ITokenService`, `IPasswordHasher` stay plain injected services (not mediator requests) since they're cross-cutting helpers called from inside other handlers/controllers, not endpoint operations themselves.
|
||
|
|
|
||
|
|
**Tech Stack:** .NET 10, ASP.NET Core, EF Core/Npgsql, AutoMapper (unchanged), MediatR (new).
|
||
|
|
|
||
|
|
**Spec:** [docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md](../specs/2026-08-13-cqrs-mediator-design.md)
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- No behavior change: every handler's logic must match the old service method's logic exactly (same exceptions, same order of checks, same repository calls).
|
||
|
|
- No test project exists in this repo — verification is `dotnet build mws.backend.dotnet.sln` (must show `0 Error(s)`) plus a manual curl smoke test in the final task.
|
||
|
|
- Solution file is `mws.backend.dotnet.sln` at repo root; actual project folders are lowercase (`mws.api/`, `mws.application/`, `mws.domain/`, `mws.infrastructure/`) despite `CLAUDE.md` referring to PascalCase paths — use the lowercase paths, they are what's on disk.
|
||
|
|
- `IPermissionService`, `ITokenService`, `IPasswordHasher` registrations and implementations are NOT touched by this plan.
|
||
|
|
- AutoMapper (`IMapper`) usage is unchanged — same `mapper.Map<T>(...)` calls, just moved into handlers.
|
||
|
|
- Commit after each task (one module = one commit), so the branch is bisectable if something regresses.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Add MediatR, wire DI, convert Auth module
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `mws.application/mws.application.csproj` (add MediatR package)
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (add `AddMediatR`, remove `AddScoped<IAuthService, AuthService>`)
|
||
|
|
- Create: `mws.application/Auth/Commands/Login.cs`
|
||
|
|
- Delete: `mws.application/Auth/AuthService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/AuthController.cs`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `Mws.Application.Auth.LoginCommand(LoginRequest Request) : IRequest<LoginResponse>` — every later task follows this same record-wraps-existing-Request-DTO pattern.
|
||
|
|
- Consumes: `Mws.Application.Auth.LoginRequest`, `LoginResponse`, `UserDto` (unchanged, from `Auth/Contracts.cs`); `IUnitOfWork`, `IPasswordHasher`, `ITokenService` (unchanged).
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add the MediatR package**
|
||
|
|
|
||
|
|
Run: `dotnet add mws.application/mws.application.csproj package MediatR`
|
||
|
|
|
||
|
|
This resolves and pins the latest stable MediatR version in `mws.application.csproj`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the Login command + handler**
|
||
|
|
|
||
|
|
Create `mws.application/Auth/Commands/Login.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Auth;
|
||
|
|
|
||
|
|
public record LoginCommand(LoginRequest Request) : IRequest<LoginResponse>;
|
||
|
|
|
||
|
|
public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper)
|
||
|
|
: IRequestHandler<LoginCommand, LoginResponse>
|
||
|
|
{
|
||
|
|
public async Task<LoginResponse> Handle(LoginCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var username = command.Request.Username.Trim();
|
||
|
|
var user = await uow.Users.GetByUsernameWithRoleAsync(username, ct);
|
||
|
|
|
||
|
|
if (user is null || !passwordHasher.Verify(command.Request.Password, user.PasswordHash))
|
||
|
|
{
|
||
|
|
throw new UnauthorizedException("Invalid username or password");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!user.IsActive)
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("Account disabled");
|
||
|
|
}
|
||
|
|
|
||
|
|
return new LoginResponse
|
||
|
|
{
|
||
|
|
Token = tokenService.CreateToken(user.Id, user.Username),
|
||
|
|
User = mapper.Map<UserDto>(user),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Delete the old service file**
|
||
|
|
|
||
|
|
Delete `mws.application/Auth/AuthService.cs` (it contained both `IAuthService` and `AuthService` — both are now replaced by `LoginCommand`/`LoginHandler`).
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update `AuthController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/AuthController.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Auth;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api/auth")]
|
||
|
|
[AllowAnonymous]
|
||
|
|
public class AuthController(ISender sender) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpPost("login")]
|
||
|
|
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var response = await sender.Send(new LoginCommand(request), ct);
|
||
|
|
return Ok(response);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Wire MediatR and drop the old registration in DI**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, add the MediatR registration right after `services.AddAutoMapper(...)`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
services.AddAutoMapper(cfg => { }, typeof(MappingProfile).Assembly);
|
||
|
|
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(IUnitOfWork).Assembly));
|
||
|
|
```
|
||
|
|
|
||
|
|
Remove the line `services.AddScoped<IAuthService, AuthService>();`.
|
||
|
|
|
||
|
|
Remove the now-unused `using Mws.Application.Auth;` only if nothing else in the file still needs it — it does (`JwtOptions`, `ITokenService` wiring live in `Mws.Application.Auth` too), so leave the using in place.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/mws.application.csproj mws.application/Auth mws.infrastructure/DependencyInjection.cs mws.api/Controllers/AuthController.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert Auth module to MediatR command
|
||
|
|
|
||
|
|
Adds the MediatR package and wires AddMediatR in DI. Replaces
|
||
|
|
IAuthService/AuthService with LoginCommand/LoginHandler, the pattern
|
||
|
|
the remaining modules will follow.
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Convert Accounts module
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `mws.application/Accounts/Queries/GetAccounts.cs`
|
||
|
|
- Create: `mws.application/Accounts/Commands/CreateAccount.cs`
|
||
|
|
- Create: `mws.application/Accounts/Commands/UpdateAccount.cs`
|
||
|
|
- Create: `mws.application/Accounts/Commands/DeleteAccount.cs`
|
||
|
|
- Create: `mws.application/Accounts/Commands/ResetPassword.cs`
|
||
|
|
- Delete: `mws.application/Accounts/IAccountService.cs`
|
||
|
|
- Delete: `mws.application/Accounts/AccountService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/AccountsController.cs`
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped<IAccountService, AccountService>`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `IUnitOfWork`, `IPasswordHasher` (`Mws.Application.Auth`), `IPermissionService`/`PermissionAction` (`Mws.Application.Permissions`) — all unchanged. `AccountDto`, `CreateAccountRequest`, `UpdateAccountRequest`, `ResetPasswordRequest` from `Accounts/Contracts.cs` — unchanged.
|
||
|
|
- Produces: `GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest<List<AccountDto>>`, `CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest<AccountDto>`, `UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest<AccountDto>`, `DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest`, `ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the query**
|
||
|
|
|
||
|
|
Create `mws.application/Accounts/Queries/GetAccounts.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
|
||
|
|
namespace Mws.Application.Accounts;
|
||
|
|
|
||
|
|
public record GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest<List<AccountDto>>;
|
||
|
|
|
||
|
|
public class GetAccountsHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
|
||
|
|
: IRequestHandler<GetAccountsQuery, List<AccountDto>>
|
||
|
|
{
|
||
|
|
private const string Screen = "accounts";
|
||
|
|
|
||
|
|
public async Task<List<AccountDto>> Handle(GetAccountsQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct);
|
||
|
|
|
||
|
|
var users = await uow.Users.SearchWithRoleAsync(query.Term, null, ct);
|
||
|
|
return mapper.Map<List<AccountDto>>(users);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the CreateAccount command**
|
||
|
|
|
||
|
|
Create `mws.application/Accounts/Commands/CreateAccount.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Auth;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Domain.Users;
|
||
|
|
|
||
|
|
namespace Mws.Application.Accounts;
|
||
|
|
|
||
|
|
public record CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest<AccountDto>;
|
||
|
|
|
||
|
|
public class CreateAccountHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper)
|
||
|
|
: IRequestHandler<CreateAccountCommand, AccountDto>
|
||
|
|
{
|
||
|
|
private const string Screen = "accounts";
|
||
|
|
|
||
|
|
public async Task<AccountDto> Handle(CreateAccountCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Create, ct);
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
var username = request.Username.Trim();
|
||
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Username and password are required");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (await uow.Users.ExistsByUsernameAsync(username, ct))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Username already exists");
|
||
|
|
}
|
||
|
|
|
||
|
|
var role = await uow.Roles.GetByIdAsync(request.RoleId, ct)
|
||
|
|
?? throw new BadRequestException("Role not found");
|
||
|
|
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var user = new User
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
Username = username,
|
||
|
|
PasswordHash = passwordHasher.Hash(request.Password),
|
||
|
|
DisplayName = request.DisplayName.Trim(),
|
||
|
|
RoleId = role.Id,
|
||
|
|
IsActive = true,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
|
||
|
|
uow.Users.Add(user);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
user.Role = role;
|
||
|
|
return mapper.Map<AccountDto>(user);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the UpdateAccount command**
|
||
|
|
|
||
|
|
Create `mws.application/Accounts/Commands/UpdateAccount.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
|
||
|
|
namespace Mws.Application.Accounts;
|
||
|
|
|
||
|
|
public record UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest<AccountDto>;
|
||
|
|
|
||
|
|
public class UpdateAccountHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper)
|
||
|
|
: IRequestHandler<UpdateAccountCommand, AccountDto>
|
||
|
|
{
|
||
|
|
private const string Screen = "accounts";
|
||
|
|
|
||
|
|
public async Task<AccountDto> Handle(UpdateAccountCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct);
|
||
|
|
|
||
|
|
var user = await uow.Users.GetByIdWithRoleAsync(command.Id, ct)
|
||
|
|
?? throw new NotFoundException("Account not found");
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
var role = await uow.Roles.GetByIdAsync(request.RoleId, ct)
|
||
|
|
?? throw new BadRequestException("Role not found");
|
||
|
|
|
||
|
|
user.DisplayName = request.DisplayName.Trim();
|
||
|
|
user.RoleId = role.Id;
|
||
|
|
user.Role = role;
|
||
|
|
user.IsActive = request.IsActive;
|
||
|
|
user.UpdatedAt = DateTime.UtcNow;
|
||
|
|
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<AccountDto>(user);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create the DeleteAccount command**
|
||
|
|
|
||
|
|
Create `mws.application/Accounts/Commands/DeleteAccount.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
|
||
|
|
namespace Mws.Application.Accounts;
|
||
|
|
|
||
|
|
public record DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest;
|
||
|
|
|
||
|
|
public class DeleteAccountHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler<DeleteAccountCommand>
|
||
|
|
{
|
||
|
|
private const string Screen = "accounts";
|
||
|
|
|
||
|
|
public async Task Handle(DeleteAccountCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Delete, ct);
|
||
|
|
|
||
|
|
var user = await uow.Users.GetByIdAsync(command.Id, ct)
|
||
|
|
?? throw new NotFoundException("Account not found");
|
||
|
|
|
||
|
|
var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(command.Id, ct);
|
||
|
|
|
||
|
|
foreach (var projectId in soleOwnerProjectIds)
|
||
|
|
{
|
||
|
|
var ownerCount = await uow.Projects.CountOwnersAsync(projectId, ct);
|
||
|
|
if (ownerCount <= 1)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Cannot delete an account that is the sole owner of a project");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
uow.Users.Remove(user);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create the ResetPassword command**
|
||
|
|
|
||
|
|
Create `mws.application/Accounts/Commands/ResetPassword.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Auth;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
|
||
|
|
namespace Mws.Application.Accounts;
|
||
|
|
|
||
|
|
public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest;
|
||
|
|
|
||
|
|
public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions)
|
||
|
|
: IRequestHandler<ResetPasswordCommand>
|
||
|
|
{
|
||
|
|
private const string Screen = "accounts";
|
||
|
|
|
||
|
|
public async Task Handle(ResetPasswordCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct);
|
||
|
|
|
||
|
|
if (string.IsNullOrWhiteSpace(command.Request.NewPassword))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("New password is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
var user = await uow.Users.GetByIdAsync(command.Id, ct)
|
||
|
|
?? throw new NotFoundException("Account not found");
|
||
|
|
|
||
|
|
user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword);
|
||
|
|
user.UpdatedAt = DateTime.UtcNow;
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Delete the old service files**
|
||
|
|
|
||
|
|
Delete `mws.application/Accounts/IAccountService.cs` and `mws.application/Accounts/AccountService.cs`.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Update `AccountsController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/AccountsController.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Accounts;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api/accounts")]
|
||
|
|
[Authorize]
|
||
|
|
public class AccountsController(ISender sender) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpGet]
|
||
|
|
public async Task<ActionResult<List<AccountDto>>> GetAll([FromQuery] string? q, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetAccountsQuery(User.GetUserId(), q), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost]
|
||
|
|
public async Task<ActionResult<AccountDto>> Create([FromBody] CreateAccountRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new CreateAccountCommand(User.GetUserId(), request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("{id:guid}")]
|
||
|
|
public async Task<ActionResult<AccountDto>> Update(Guid id, [FromBody] UpdateAccountRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new UpdateAccountCommand(User.GetUserId(), id, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpDelete("{id:guid}")]
|
||
|
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new DeleteAccountCommand(User.GetUserId(), id), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost("{id:guid}/reset-password")]
|
||
|
|
public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new ResetPasswordCommand(User.GetUserId(), id, request), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Remove the old DI registration**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped<IAccountService, AccountService>();`.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 10: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/Accounts mws.api/Controllers/AccountsController.cs mws.infrastructure/DependencyInjection.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert Accounts module to MediatR commands/queries
|
||
|
|
|
||
|
|
Replaces IAccountService/AccountService with one command/query per
|
||
|
|
operation, following the pattern set in the Auth module.
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Convert Roles module
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `mws.application/Roles/RolePermissionBuilder.cs`
|
||
|
|
- Create: `mws.application/Roles/Queries/GetRoles.cs`
|
||
|
|
- Create: `mws.application/Roles/Queries/GetRole.cs`
|
||
|
|
- Create: `mws.application/Roles/Commands/CreateRole.cs`
|
||
|
|
- Create: `mws.application/Roles/Commands/UpdateRole.cs`
|
||
|
|
- Create: `mws.application/Roles/Commands/DeleteRole.cs`
|
||
|
|
- Delete: `mws.application/Roles/IRoleService.cs`
|
||
|
|
- Delete: `mws.application/Roles/RoleService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/RolesController.cs`
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped<IRoleService, RoleService>`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `RolePermissionBuilder.Build(List<PermissionEntryDto>) : List<RolePermission>` — shared by `CreateRoleHandler` and `UpdateRoleHandler`, replacing the old `RoleService.BuildPermissions` private method.
|
||
|
|
- Produces: `GetRolesQuery : IRequest<List<RoleDto>>`, `GetRoleQuery(Guid Id) : IRequest<RoleDto>`, `CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>`, `UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>`, `DeleteRoleCommand(Guid Id) : IRequest`.
|
||
|
|
- Consumes: `IPermissionService.EnsureAsync` stays called directly from `RolesController`, unchanged — the permission check is NOT moved into the handlers (it wasn't in the old `RoleService` either; it lived in the controller).
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the shared permission-builder helper**
|
||
|
|
|
||
|
|
Create `mws.application/Roles/RolePermissionBuilder.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Domain.Roles;
|
||
|
|
|
||
|
|
namespace Mws.Application.Roles;
|
||
|
|
|
||
|
|
internal static class RolePermissionBuilder
|
||
|
|
{
|
||
|
|
public static List<RolePermission> Build(List<PermissionEntryDto> entries)
|
||
|
|
{
|
||
|
|
var byScreen = entries.Where(e => ScreenCatalog.Keys.Contains(e.Screen)).ToDictionary(e => e.Screen);
|
||
|
|
|
||
|
|
return ScreenCatalog.Keys.Select(key =>
|
||
|
|
{
|
||
|
|
byScreen.TryGetValue(key, out var entry);
|
||
|
|
return new RolePermission
|
||
|
|
{
|
||
|
|
Screen = key,
|
||
|
|
CanView = entry?.CanView ?? false,
|
||
|
|
CanCreate = entry?.CanCreate ?? false,
|
||
|
|
CanEdit = entry?.CanEdit ?? false,
|
||
|
|
CanDelete = entry?.CanDelete ?? false,
|
||
|
|
};
|
||
|
|
}).ToList();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the queries**
|
||
|
|
|
||
|
|
Create `mws.application/Roles/Queries/GetRoles.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Roles;
|
||
|
|
|
||
|
|
public record GetRolesQuery : IRequest<List<RoleDto>>;
|
||
|
|
|
||
|
|
public class GetRolesHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetRolesQuery, List<RoleDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<RoleDto>> Handle(GetRolesQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var roles = await uow.Roles.GetAllWithPermissionsAsync(ct);
|
||
|
|
return mapper.Map<List<RoleDto>>(roles);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Roles/Queries/GetRole.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Roles;
|
||
|
|
|
||
|
|
public record GetRoleQuery(Guid Id) : IRequest<RoleDto>;
|
||
|
|
|
||
|
|
public class GetRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetRoleQuery, RoleDto>
|
||
|
|
{
|
||
|
|
public async Task<RoleDto> Handle(GetRoleQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var role = await uow.Roles.GetByIdWithPermissionsAsync(query.Id, ct)
|
||
|
|
?? throw new NotFoundException("Role not found");
|
||
|
|
return mapper.Map<RoleDto>(role);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the CreateRole command**
|
||
|
|
|
||
|
|
Create `mws.application/Roles/Commands/CreateRole.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Roles;
|
||
|
|
|
||
|
|
namespace Mws.Application.Roles;
|
||
|
|
|
||
|
|
public record CreateRoleCommand(SaveRoleRequest Request) : IRequest<RoleDto>;
|
||
|
|
|
||
|
|
public class CreateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateRoleCommand, RoleDto>
|
||
|
|
{
|
||
|
|
public async Task<RoleDto> Handle(CreateRoleCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var name = command.Request.Name.Trim();
|
||
|
|
if (string.IsNullOrWhiteSpace(name))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Role name is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (await uow.Roles.ExistsByNameAsync(name, null, ct))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Role name already exists");
|
||
|
|
}
|
||
|
|
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var role = new Role
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
Name = name,
|
||
|
|
IsSystem = false,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
Permissions = RolePermissionBuilder.Build(command.Request.Permissions),
|
||
|
|
};
|
||
|
|
|
||
|
|
uow.Roles.Add(role);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<RoleDto>(role);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create the UpdateRole command**
|
||
|
|
|
||
|
|
Create `mws.application/Roles/Commands/UpdateRole.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Roles;
|
||
|
|
|
||
|
|
public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest<RoleDto>;
|
||
|
|
|
||
|
|
public class UpdateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateRoleCommand, RoleDto>
|
||
|
|
{
|
||
|
|
public async Task<RoleDto> Handle(UpdateRoleCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var role = await uow.Roles.GetByIdWithPermissionsAsync(command.Id, ct)
|
||
|
|
?? throw new NotFoundException("Role not found");
|
||
|
|
|
||
|
|
var name = command.Request.Name.Trim();
|
||
|
|
if (string.IsNullOrWhiteSpace(name))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Role name is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (await uow.Roles.ExistsByNameAsync(name, command.Id, ct))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Role name already exists");
|
||
|
|
}
|
||
|
|
|
||
|
|
role.Name = name;
|
||
|
|
role.UpdatedAt = DateTime.UtcNow;
|
||
|
|
|
||
|
|
uow.Roles.RemovePermissions(role.Permissions.ToList());
|
||
|
|
role.Permissions = RolePermissionBuilder.Build(command.Request.Permissions);
|
||
|
|
foreach (var p in role.Permissions)
|
||
|
|
{
|
||
|
|
p.RoleId = role.Id;
|
||
|
|
}
|
||
|
|
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<RoleDto>(role);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create the DeleteRole command**
|
||
|
|
|
||
|
|
Create `mws.application/Roles/Commands/DeleteRole.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Roles;
|
||
|
|
|
||
|
|
public record DeleteRoleCommand(Guid Id) : IRequest;
|
||
|
|
|
||
|
|
public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler<DeleteRoleCommand>
|
||
|
|
{
|
||
|
|
public async Task Handle(DeleteRoleCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var role = await uow.Roles.GetByIdAsync(command.Id, ct)
|
||
|
|
?? throw new NotFoundException("Role not found");
|
||
|
|
|
||
|
|
if (role.IsSystem)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Cannot delete a system role");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (await uow.Users.ExistsByRoleIdAsync(command.Id, ct))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Cannot delete a role that is assigned to accounts");
|
||
|
|
}
|
||
|
|
|
||
|
|
uow.Roles.Remove(role);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Delete the old service files**
|
||
|
|
|
||
|
|
Delete `mws.application/Roles/IRoleService.cs` and `mws.application/Roles/RoleService.cs`.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Update `RolesController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/RolesController.cs` (the inline `IPermissionService.EnsureAsync` calls stay exactly as they were):
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Application.Roles;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api/roles")]
|
||
|
|
[Authorize]
|
||
|
|
public class RolesController(ISender sender, IPermissionService permissions) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpGet]
|
||
|
|
public async Task<ActionResult<List<RoleDto>>> GetAll(CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct);
|
||
|
|
return Ok(await sender.Send(new GetRolesQuery(), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("{id:guid}")]
|
||
|
|
public async Task<ActionResult<RoleDto>> Get(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct);
|
||
|
|
return Ok(await sender.Send(new GetRoleQuery(id), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost]
|
||
|
|
public async Task<ActionResult<RoleDto>> Create([FromBody] SaveRoleRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Create, ct);
|
||
|
|
return Ok(await sender.Send(new CreateRoleCommand(request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("{id:guid}")]
|
||
|
|
public async Task<ActionResult<RoleDto>> Update(Guid id, [FromBody] SaveRoleRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Edit, ct);
|
||
|
|
return Ok(await sender.Send(new UpdateRoleCommand(id, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpDelete("{id:guid}")]
|
||
|
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Delete, ct);
|
||
|
|
await sender.Send(new DeleteRoleCommand(id), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Remove the old DI registration**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped<IRoleService, RoleService>();`.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 10: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/Roles mws.api/Controllers/RolesController.cs mws.infrastructure/DependencyInjection.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert Roles module to MediatR commands/queries
|
||
|
|
|
||
|
|
Replaces IRoleService/RoleService. The permission-builder logic moves
|
||
|
|
to a shared RolePermissionBuilder static helper used by both the
|
||
|
|
create and update handlers. RolesController keeps its inline
|
||
|
|
IPermissionService checks unchanged.
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Convert Projects module (project CRUD, not membership)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `mws.application/Projects/ProjectAccess.cs`
|
||
|
|
- Create: `mws.application/Projects/Queries/GetProjects.cs`
|
||
|
|
- Create: `mws.application/Projects/Queries/GetProject.cs`
|
||
|
|
- Create: `mws.application/Projects/Queries/SearchProjects.cs`
|
||
|
|
- Create: `mws.application/Projects/Queries/GetProjectOverview.cs`
|
||
|
|
- Create: `mws.application/Projects/Commands/CreateProject.cs`
|
||
|
|
- Create: `mws.application/Projects/Commands/UpdateProject.cs`
|
||
|
|
- Create: `mws.application/Projects/Commands/ArchiveProject.cs`
|
||
|
|
- Modify: `mws.application/Projects/IProjectService.cs` (remove `IProjectService`, keep `IProjectMemberService` — Task 5 removes it)
|
||
|
|
- Delete: `mws.application/Projects/ProjectService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/ProjectsController.cs`
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped<IProjectService, ProjectService>`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `ProjectAccess.GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) : Task<Project>` — shared by `GetProjectHandler`, `UpdateProjectHandler`, `ArchiveProjectHandler`, `GetProjectOverviewHandler`.
|
||
|
|
- Produces: `GetProjectsQuery(Guid UserId) : IRequest<List<ProjectDto>>`, `GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectDto>`, `SearchProjectsQuery(Guid UserId, string Term) : IRequest<List<ProjectDto>>`, `GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectOverviewDto>`, `CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>`, `UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>`, `ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest`.
|
||
|
|
- Note: `IProjectMemberService` (Task 5's target) stays in `IProjectService.cs` for this task — do not delete it yet, `ProjectMembersController` still depends on it until Task 5.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the shared project-access helper**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/ProjectAccess.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
internal static class ProjectAccess
|
||
|
|
{
|
||
|
|
public static async Task<Project> GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return await uow.Projects.GetForUserAsync(userId, projectId, ct)
|
||
|
|
?? throw new NotFoundException("Project not found");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the queries**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Queries/GetProjects.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record GetProjectsQuery(Guid UserId) : IRequest<List<ProjectDto>>;
|
||
|
|
|
||
|
|
public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectsQuery, List<ProjectDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<ProjectDto>> Handle(GetProjectsQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var projects = await uow.Projects.GetForUserAsync(query.UserId, ct);
|
||
|
|
return mapper.Map<List<ProjectDto>>(projects);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Queries/GetProject.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectDto>;
|
||
|
|
|
||
|
|
public class GetProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectQuery, ProjectDto>
|
||
|
|
{
|
||
|
|
public async Task<ProjectDto> Handle(GetProjectQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct);
|
||
|
|
return mapper.Map<ProjectDto>(project);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Queries/SearchProjects.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record SearchProjectsQuery(Guid UserId, string Term) : IRequest<List<ProjectDto>>;
|
||
|
|
|
||
|
|
public class SearchProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<SearchProjectsQuery, List<ProjectDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<ProjectDto>> Handle(SearchProjectsQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var projects = await uow.Projects.SearchForUserAsync(query.UserId, query.Term, ct);
|
||
|
|
return mapper.Map<List<ProjectDto>>(projects);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Queries/GetProjectOverview.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Documents;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest<ProjectOverviewDto>;
|
||
|
|
|
||
|
|
public class GetProjectOverviewHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetProjectOverviewQuery, ProjectOverviewDto>
|
||
|
|
{
|
||
|
|
public async Task<ProjectOverviewDto> Handle(GetProjectOverviewQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct);
|
||
|
|
|
||
|
|
var memberCount = await uow.Projects.CountMembersAsync(query.ProjectId, ct);
|
||
|
|
var documentCount = await uow.Documents.CountByTypeForProjectAsync(query.ProjectId, DocumentType.Document, ct);
|
||
|
|
|
||
|
|
var taskCounts = await uow.Tasks.CountByStatusForProjectAsync(query.ProjectId, ct);
|
||
|
|
var taskCountsByStatus = taskCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value);
|
||
|
|
|
||
|
|
var recentTasks = await uow.Tasks.GetRecentForProjectAsync(query.ProjectId, 5, ct);
|
||
|
|
var recentDocuments = await uow.Documents.GetRecentForProjectAsync(query.ProjectId, DocumentType.Document, 5, ct);
|
||
|
|
|
||
|
|
return new ProjectOverviewDto
|
||
|
|
{
|
||
|
|
Project = mapper.Map<ProjectDto>(project),
|
||
|
|
MemberCount = memberCount,
|
||
|
|
DocumentCount = documentCount,
|
||
|
|
TaskCountsByStatus = taskCountsByStatus,
|
||
|
|
RecentTasks = mapper.Map<List<RecentTaskDto>>(recentTasks),
|
||
|
|
RecentDocuments = mapper.Map<List<RecentDocumentDto>>(recentDocuments),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the CreateProject command**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Commands/CreateProject.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest<ProjectDto>;
|
||
|
|
|
||
|
|
public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateProjectCommand, ProjectDto>
|
||
|
|
{
|
||
|
|
public async Task<ProjectDto> Handle(CreateProjectCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var request = command.Request;
|
||
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Project name is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var project = new Project
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
Name = request.Name.Trim(),
|
||
|
|
Description = request.Description,
|
||
|
|
Status = ProjectStatus.Active,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
|
||
|
|
project.Members.Add(new ProjectMember
|
||
|
|
{
|
||
|
|
ProjectId = project.Id,
|
||
|
|
UserId = command.UserId,
|
||
|
|
Role = MemberRole.Owner,
|
||
|
|
});
|
||
|
|
|
||
|
|
uow.Projects.Add(project);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<ProjectDto>(project);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create the UpdateProject command**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Commands/UpdateProject.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest<ProjectDto>;
|
||
|
|
|
||
|
|
public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateProjectCommand, ProjectDto>
|
||
|
|
{
|
||
|
|
public async Task<ProjectDto> Handle(UpdateProjectCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct);
|
||
|
|
|
||
|
|
if (MemberRole.Owner != await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct))
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("Only the project owner can update the project");
|
||
|
|
}
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Project name is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
project.Name = request.Name.Trim();
|
||
|
|
project.Description = request.Description;
|
||
|
|
project.Status = request.Status;
|
||
|
|
project.UpdatedAt = DateTime.UtcNow;
|
||
|
|
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<ProjectDto>(project);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create the ArchiveProject command**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Commands/ArchiveProject.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest;
|
||
|
|
|
||
|
|
public class ArchiveProjectHandler(IUnitOfWork uow) : IRequestHandler<ArchiveProjectCommand>
|
||
|
|
{
|
||
|
|
public async Task Handle(ArchiveProjectCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct);
|
||
|
|
|
||
|
|
if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("Only the project owner can archive the project");
|
||
|
|
}
|
||
|
|
|
||
|
|
project.Status = ProjectStatus.Archived;
|
||
|
|
project.UpdatedAt = DateTime.UtcNow;
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Remove `IProjectService` from `IProjectService.cs`, keep `IProjectMemberService`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.application/Projects/IProjectService.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public interface IProjectMemberService
|
||
|
|
{
|
||
|
|
Task<List<ProjectMemberDto>> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
||
|
|
Task<ProjectMemberDto> AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default);
|
||
|
|
Task<ProjectMemberDto> UpdateMemberDocumentPermissionsAsync(Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default);
|
||
|
|
Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Delete `ProjectService.cs`**
|
||
|
|
|
||
|
|
Delete `mws.application/Projects/ProjectService.cs`.
|
||
|
|
|
||
|
|
- [ ] **Step 8: Update `ProjectsController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/ProjectsController.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api/projects")]
|
||
|
|
[Authorize]
|
||
|
|
public class ProjectsController(ISender sender) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpGet]
|
||
|
|
public async Task<ActionResult<List<ProjectDto>>> GetAll(CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId()), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("search")]
|
||
|
|
public async Task<ActionResult<List<ProjectDto>>> Search([FromQuery] string? q, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new SearchProjectsQuery(User.GetUserId(), q ?? string.Empty), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("{projectId:guid}")]
|
||
|
|
public async Task<ActionResult<ProjectDto>> Get(Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetProjectQuery(User.GetUserId(), projectId), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("{projectId:guid}/overview")]
|
||
|
|
public async Task<ActionResult<ProjectOverviewDto>> Overview(Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetProjectOverviewQuery(User.GetUserId(), projectId), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost]
|
||
|
|
public async Task<ActionResult<ProjectDto>> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var result = await sender.Send(new CreateProjectCommand(User.GetUserId(), request), ct);
|
||
|
|
return CreatedAtAction(nameof(Get), new { projectId = result.Id }, result);
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("{projectId:guid}")]
|
||
|
|
public async Task<ActionResult<ProjectDto>> Update(Guid projectId, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new UpdateProjectCommand(User.GetUserId(), projectId, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpDelete("{projectId:guid}")]
|
||
|
|
public async Task<IActionResult> Delete(Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new ArchiveProjectCommand(User.GetUserId(), projectId), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 9: Remove the old DI registration**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped<IProjectService, ProjectService>();`. Leave `services.AddScoped<IProjectMemberService, ProjectMemberService>();` in place — Task 5 removes it.
|
||
|
|
|
||
|
|
- [ ] **Step 10: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 11: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/Projects mws.api/Controllers/ProjectsController.cs mws.infrastructure/DependencyInjection.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert Projects module to MediatR commands/queries
|
||
|
|
|
||
|
|
Replaces IProjectService/ProjectService. IProjectMemberService is
|
||
|
|
left in place for now — ProjectMembersController still depends on it
|
||
|
|
until the next task converts it too.
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Convert ProjectMembers module
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `mws.application/Projects/Queries/GetProjectMembers.cs`
|
||
|
|
- Create: `mws.application/Projects/Commands/AddProjectMember.cs`
|
||
|
|
- Create: `mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs`
|
||
|
|
- Create: `mws.application/Projects/Commands/RemoveProjectMember.cs`
|
||
|
|
- Delete: `mws.application/Projects/IProjectService.cs` (now contains only `IProjectMemberService`, no longer needed)
|
||
|
|
- Delete: `mws.application/Projects/ProjectMemberService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/ProjectMembersController.cs`
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped<IProjectMemberService, ProjectMemberService>`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest<List<ProjectMemberDto>>`, `AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>`, `UpdateMemberDocumentPermissionsCommand(Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>`, `RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest`.
|
||
|
|
- Consumes: `IUnitOfWork.Projects.GetMemberRoleAsync(projectId, userId, ct)` called directly (not wrapped in a helper — it was already a one-line delegate in the old service).
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the query**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Queries/GetProjectMembers.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record GetProjectMembersQuery(Guid UserId, Guid ProjectId) : IRequest<List<ProjectMemberDto>>;
|
||
|
|
|
||
|
|
public class GetProjectMembersHandler(IUnitOfWork uow) : IRequestHandler<GetProjectMembersQuery, List<ProjectMemberDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<ProjectMemberDto>> Handle(GetProjectMembersQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var isMember = await uow.Projects.IsMemberAsync(query.ProjectId, query.UserId, ct);
|
||
|
|
if (!isMember)
|
||
|
|
{
|
||
|
|
throw new NotFoundException("Project not found");
|
||
|
|
}
|
||
|
|
|
||
|
|
var members = await uow.Projects.GetMembersWithUserAsync(query.ProjectId, ct);
|
||
|
|
var permissions = await uow.Projects.GetMemberPermissionsAsync(query.ProjectId, ProjectPermissionScreens.Documents, ct);
|
||
|
|
|
||
|
|
return members.Select(m =>
|
||
|
|
{
|
||
|
|
permissions.TryGetValue(m.UserId, out var p);
|
||
|
|
return new ProjectMemberDto
|
||
|
|
{
|
||
|
|
UserId = m.UserId,
|
||
|
|
Username = m.User.Username,
|
||
|
|
DisplayName = m.User.DisplayName,
|
||
|
|
Role = m.Role,
|
||
|
|
CanViewDocuments = m.Role == MemberRole.Owner || (p?.CanView ?? false),
|
||
|
|
CanCreateDocuments = m.Role == MemberRole.Owner || (p?.CanCreate ?? false),
|
||
|
|
CanEditDocuments = m.Role == MemberRole.Owner || (p?.CanEdit ?? false),
|
||
|
|
CanDeleteDocuments = m.Role == MemberRole.Owner || (p?.CanDelete ?? false),
|
||
|
|
};
|
||
|
|
}).ToList();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the AddProjectMember command**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Commands/AddProjectMember.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest<ProjectMemberDto>;
|
||
|
|
|
||
|
|
public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<AddProjectMemberCommand, ProjectMemberDto>
|
||
|
|
{
|
||
|
|
public async Task<ProjectMemberDto> Handle(AddProjectMemberCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("Only the project owner can add members");
|
||
|
|
}
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
var user = await uow.Users.GetByIdAsync(request.UserId, ct)
|
||
|
|
?? throw new NotFoundException("User not found");
|
||
|
|
|
||
|
|
var already = await uow.Projects.IsMemberAsync(command.ProjectId, request.UserId, ct);
|
||
|
|
if (already)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("User is already a member of this project");
|
||
|
|
}
|
||
|
|
|
||
|
|
var role = request.Role is MemberRole.Owner or MemberRole.Member ? request.Role : MemberRole.Member;
|
||
|
|
var member = new ProjectMember
|
||
|
|
{
|
||
|
|
ProjectId = command.ProjectId,
|
||
|
|
UserId = request.UserId,
|
||
|
|
Role = role,
|
||
|
|
};
|
||
|
|
|
||
|
|
uow.Projects.AddMember(member);
|
||
|
|
uow.Projects.AddMemberPermission(new ProjectMemberPermission
|
||
|
|
{
|
||
|
|
ProjectId = command.ProjectId,
|
||
|
|
UserId = request.UserId,
|
||
|
|
Screen = ProjectPermissionScreens.Documents,
|
||
|
|
CanView = true,
|
||
|
|
CanCreate = role == MemberRole.Owner,
|
||
|
|
CanEdit = role == MemberRole.Owner,
|
||
|
|
CanDelete = role == MemberRole.Owner,
|
||
|
|
});
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return new ProjectMemberDto
|
||
|
|
{
|
||
|
|
UserId = user.Id,
|
||
|
|
Username = user.Username,
|
||
|
|
DisplayName = user.DisplayName,
|
||
|
|
Role = member.Role,
|
||
|
|
CanViewDocuments = true,
|
||
|
|
CanCreateDocuments = role == MemberRole.Owner,
|
||
|
|
CanEditDocuments = role == MemberRole.Owner,
|
||
|
|
CanDeleteDocuments = role == MemberRole.Owner,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the UpdateMemberDocumentPermissions command**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Commands/UpdateMemberDocumentPermissions.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record UpdateMemberDocumentPermissionsCommand(
|
||
|
|
Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest<ProjectMemberDto>;
|
||
|
|
|
||
|
|
public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow)
|
||
|
|
: IRequestHandler<UpdateMemberDocumentPermissionsCommand, ProjectMemberDto>
|
||
|
|
{
|
||
|
|
public async Task<ProjectMemberDto> Handle(UpdateMemberDocumentPermissionsCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("Only the project owner can change member permissions");
|
||
|
|
}
|
||
|
|
|
||
|
|
var member = await uow.Projects.GetMemberWithUserAsync(command.ProjectId, command.MemberUserId, ct)
|
||
|
|
?? throw new NotFoundException("Member not found in project");
|
||
|
|
|
||
|
|
if (member.Role == MemberRole.Owner)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Owner permissions cannot be changed");
|
||
|
|
}
|
||
|
|
|
||
|
|
var permission = await uow.Projects.GetMemberPermissionAsync(command.ProjectId, command.MemberUserId, ProjectPermissionScreens.Documents, ct);
|
||
|
|
if (permission is null)
|
||
|
|
{
|
||
|
|
permission = new ProjectMemberPermission { ProjectId = command.ProjectId, UserId = command.MemberUserId, Screen = ProjectPermissionScreens.Documents };
|
||
|
|
uow.Projects.AddMemberPermission(permission);
|
||
|
|
}
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
permission.CanView = request.CanViewDocuments;
|
||
|
|
permission.CanCreate = request.CanCreateDocuments;
|
||
|
|
permission.CanEdit = request.CanEditDocuments;
|
||
|
|
permission.CanDelete = request.CanDeleteDocuments;
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
|
||
|
|
return new ProjectMemberDto
|
||
|
|
{
|
||
|
|
UserId = member.UserId,
|
||
|
|
Username = member.User.Username,
|
||
|
|
DisplayName = member.User.DisplayName,
|
||
|
|
Role = member.Role,
|
||
|
|
CanViewDocuments = permission.CanView,
|
||
|
|
CanCreateDocuments = permission.CanCreate,
|
||
|
|
CanEditDocuments = permission.CanEdit,
|
||
|
|
CanDeleteDocuments = permission.CanDelete,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create the RemoveProjectMember command**
|
||
|
|
|
||
|
|
Create `mws.application/Projects/Commands/RemoveProjectMember.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Projects;
|
||
|
|
|
||
|
|
public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest;
|
||
|
|
|
||
|
|
public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler<RemoveProjectMemberCommand>
|
||
|
|
{
|
||
|
|
public async Task Handle(RemoveProjectMemberCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner)
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("Only the project owner can remove members");
|
||
|
|
}
|
||
|
|
|
||
|
|
var member = await uow.Projects.GetMemberAsync(command.ProjectId, command.MemberUserId, ct)
|
||
|
|
?? throw new NotFoundException("Member not found in project");
|
||
|
|
|
||
|
|
var owners = await uow.Projects.CountOwnersAsync(command.ProjectId, ct);
|
||
|
|
|
||
|
|
if (member.Role == MemberRole.Owner && owners <= 1)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Cannot remove the last owner of the project");
|
||
|
|
}
|
||
|
|
|
||
|
|
uow.Projects.RemoveMember(member);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Delete the old service files**
|
||
|
|
|
||
|
|
Delete `mws.application/Projects/IProjectService.cs` (it now contains only `IProjectMemberService`, which is fully replaced) and `mws.application/Projects/ProjectMemberService.cs`.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Update `ProjectMembersController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/ProjectMembersController.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api/projects/{projectId:guid}/members")]
|
||
|
|
[Authorize]
|
||
|
|
public class ProjectMembersController(ISender sender) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpGet]
|
||
|
|
public async Task<ActionResult<List<ProjectMemberDto>>> GetAll(Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetProjectMembersQuery(User.GetUserId(), projectId), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost]
|
||
|
|
public async Task<ActionResult<ProjectMemberDto>> Add(Guid projectId, [FromBody] AddMemberRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new AddProjectMemberCommand(User.GetUserId(), projectId, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("{userId:guid}/document-permissions")]
|
||
|
|
public async Task<ActionResult<ProjectMemberDto>> UpdateDocumentPermissions(
|
||
|
|
Guid projectId, Guid userId, [FromBody] UpdateMemberDocumentPermissionsRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new UpdateMemberDocumentPermissionsCommand(User.GetUserId(), projectId, userId, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpDelete("{userId:guid}")]
|
||
|
|
public async Task<IActionResult> Remove(Guid projectId, Guid userId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new RemoveProjectMemberCommand(User.GetUserId(), projectId, userId), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Remove the old DI registration**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped<IProjectMemberService, ProjectMemberService>();`.
|
||
|
|
|
||
|
|
- [ ] **Step 8: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/Projects mws.api/Controllers/ProjectMembersController.cs mws.infrastructure/DependencyInjection.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert ProjectMembers module to MediatR commands/queries
|
||
|
|
|
||
|
|
Replaces IProjectMemberService/ProjectMemberService, completing the
|
||
|
|
Projects module conversion.
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: Convert Documents module
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `mws.application/Documents/DocumentAccess.cs`
|
||
|
|
- Create: `mws.application/Documents/Queries/GetDocumentTree.cs`
|
||
|
|
- Create: `mws.application/Documents/Queries/GetDocument.cs`
|
||
|
|
- Create: `mws.application/Documents/Queries/SearchDocuments.cs`
|
||
|
|
- Create: `mws.application/Documents/Commands/CreateDocument.cs`
|
||
|
|
- Create: `mws.application/Documents/Commands/UpdateDocument.cs`
|
||
|
|
- Create: `mws.application/Documents/Commands/MoveDocument.cs`
|
||
|
|
- Create: `mws.application/Documents/Commands/DeleteDocument.cs`
|
||
|
|
- Delete: `mws.application/Documents/IDocumentService.cs`
|
||
|
|
- Delete: `mws.application/Documents/DocumentService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/DocumentsController.cs`
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped<IDocumentService, DocumentService>`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `DocumentAccess.EnsureDocumentPermissionAsync(uow, userId, projectId, action, ct)`, `DocumentAccess.HasDocumentPermissionAsync(uow, member, action, ct) : Task<bool>`, `DocumentAccess.GetDocumentForUserAsync(uow, userId, documentId, ct) : Task<Document>`, `DocumentAccess.DeleteDescendantsAsync(uow, parentId, ct)` — shared across every Documents handler below.
|
||
|
|
- Produces: `GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest<List<DocumentNodeDto>>`, `GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest<DocumentDto>`, `SearchDocumentsQuery(Guid UserId, string Term) : IRequest<List<DocumentNodeDto>>`, `CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>`, `UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>`, `MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest`, `DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the shared document-access helper**
|
||
|
|
|
||
|
|
Create `mws.application/Documents/DocumentAccess.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Domain.Documents;
|
||
|
|
using Mws.Domain.Projects;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
internal static class DocumentAccess
|
||
|
|
{
|
||
|
|
public static async Task EnsureDocumentPermissionAsync(
|
||
|
|
IUnitOfWork uow, Guid userId, Guid projectId, PermissionAction action, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var member = await uow.Projects.GetMemberAsync(projectId, userId, ct)
|
||
|
|
?? throw new NotFoundException("Project not found");
|
||
|
|
|
||
|
|
if (!await HasDocumentPermissionAsync(uow, member, action, ct))
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("You do not have permission to access documents in this project");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<bool> HasDocumentPermissionAsync(
|
||
|
|
IUnitOfWork uow, ProjectMember member, PermissionAction action, CancellationToken ct)
|
||
|
|
{
|
||
|
|
if (member.Role == MemberRole.Owner)
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
var permission = await uow.Projects.GetMemberPermissionAsync(member.ProjectId, member.UserId, ProjectPermissionScreens.Documents, ct);
|
||
|
|
|
||
|
|
return action switch
|
||
|
|
{
|
||
|
|
PermissionAction.View => permission?.CanView ?? false,
|
||
|
|
PermissionAction.Create => permission?.CanCreate ?? false,
|
||
|
|
PermissionAction.Edit => permission?.CanEdit ?? false,
|
||
|
|
PermissionAction.Delete => permission?.CanDelete ?? false,
|
||
|
|
_ => false,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<Document> GetDocumentForUserAsync(IUnitOfWork uow, Guid userId, Guid documentId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var doc = await uow.Documents.GetByIdAsync(documentId, ct)
|
||
|
|
?? throw new NotFoundException("Document not found");
|
||
|
|
|
||
|
|
var member = await uow.Projects.GetMemberAsync(doc.ProjectId, userId, ct);
|
||
|
|
|
||
|
|
if (member is null)
|
||
|
|
{
|
||
|
|
throw new NotFoundException("Document not found");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!await HasDocumentPermissionAsync(uow, member, PermissionAction.View, ct))
|
||
|
|
{
|
||
|
|
throw new ForbiddenException("You do not have permission to view this document");
|
||
|
|
}
|
||
|
|
|
||
|
|
return doc;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task DeleteDescendantsAsync(IUnitOfWork uow, Guid parentId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var children = await uow.Documents.GetChildrenAsync(parentId, ct);
|
||
|
|
foreach (var child in children)
|
||
|
|
{
|
||
|
|
await DeleteDescendantsAsync(uow, child.Id, ct);
|
||
|
|
uow.Documents.Remove(child);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the queries**
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Queries/GetDocumentTree.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record GetDocumentTreeQuery(Guid UserId, Guid ProjectId) : IRequest<List<DocumentNodeDto>>;
|
||
|
|
|
||
|
|
public class GetDocumentTreeHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetDocumentTreeQuery, List<DocumentNodeDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<DocumentNodeDto>> Handle(GetDocumentTreeQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await DocumentAccess.EnsureDocumentPermissionAsync(uow, query.UserId, query.ProjectId, PermissionAction.View, ct);
|
||
|
|
|
||
|
|
var docs = await uow.Documents.GetTreeForProjectAsync(query.ProjectId, ct);
|
||
|
|
|
||
|
|
var nodes = docs.ToDictionary(d => d.Id, d => mapper.Map<DocumentNodeDto>(d));
|
||
|
|
|
||
|
|
var roots = new List<DocumentNodeDto>();
|
||
|
|
foreach (var node in nodes.Values)
|
||
|
|
{
|
||
|
|
if (node.ParentId is { } parentId && nodes.TryGetValue(parentId, out var parent))
|
||
|
|
{
|
||
|
|
parent.Children.Add(node);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
roots.Add(node);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return roots;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Queries/GetDocument.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record GetDocumentQuery(Guid UserId, Guid DocumentId) : IRequest<DocumentDto>;
|
||
|
|
|
||
|
|
public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetDocumentQuery, DocumentDto>
|
||
|
|
{
|
||
|
|
public async Task<DocumentDto> Handle(GetDocumentQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct);
|
||
|
|
return mapper.Map<DocumentDto>(doc);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Queries/SearchDocuments.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record SearchDocumentsQuery(Guid UserId, string Term) : IRequest<List<DocumentNodeDto>>;
|
||
|
|
|
||
|
|
public class SearchDocumentsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<SearchDocumentsQuery, List<DocumentNodeDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<DocumentNodeDto>> Handle(SearchDocumentsQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var projectIds = await uow.Projects.GetDocumentViewableProjectIdsAsync(query.UserId, ct);
|
||
|
|
var results = await uow.Documents.SearchAsync(projectIds, query.Term, 20, ct);
|
||
|
|
return mapper.Map<List<DocumentNodeDto>>(results);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the CreateDocument command**
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Commands/CreateDocument.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Domain.Documents;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest<DocumentDto>;
|
||
|
|
|
||
|
|
public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateDocumentCommand, DocumentDto>
|
||
|
|
{
|
||
|
|
public async Task<DocumentDto> Handle(CreateDocumentCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, command.ProjectId, PermissionAction.Create, ct);
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Title is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.ParentId is { } parentId)
|
||
|
|
{
|
||
|
|
var parent = await uow.Documents.GetInProjectAsync(parentId, command.ProjectId, ct)
|
||
|
|
?? throw new BadRequestException("Parent document not found");
|
||
|
|
if (parent.Type != DocumentType.Folder)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Parent must be a folder");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Type == DocumentType.Folder && !string.IsNullOrWhiteSpace(request.Content))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Folders cannot have content");
|
||
|
|
}
|
||
|
|
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var doc = new Document
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
ProjectId = command.ProjectId,
|
||
|
|
ParentId = request.ParentId,
|
||
|
|
Title = request.Title.Trim(),
|
||
|
|
Type = request.Type,
|
||
|
|
Content = request.Type == DocumentType.Document ? request.Content : null,
|
||
|
|
CreatedBy = command.UserId,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedBy = command.UserId,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
|
||
|
|
uow.Documents.Add(doc);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<DocumentDto>(doc);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create the UpdateDocument command**
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Commands/UpdateDocument.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Domain.Documents;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest<DocumentDto>;
|
||
|
|
|
||
|
|
public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateDocumentCommand, DocumentDto>
|
||
|
|
{
|
||
|
|
public async Task<DocumentDto> Handle(UpdateDocumentCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
|
||
|
|
await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Edit, ct);
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Title is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
doc.Title = request.Title.Trim();
|
||
|
|
if (doc.Type == DocumentType.Document)
|
||
|
|
{
|
||
|
|
doc.Content = request.Content;
|
||
|
|
doc.UpdatedAt = DateTime.UtcNow;
|
||
|
|
doc.UpdatedBy = command.UserId;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
doc.UpdatedAt = DateTime.UtcNow;
|
||
|
|
doc.UpdatedBy = command.UserId;
|
||
|
|
}
|
||
|
|
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return mapper.Map<DocumentDto>(doc);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create the MoveDocument command**
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Commands/MoveDocument.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
using Mws.Domain.Documents;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest;
|
||
|
|
|
||
|
|
public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler<MoveDocumentCommand>
|
||
|
|
{
|
||
|
|
public async Task Handle(MoveDocumentCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
|
||
|
|
await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Edit, ct);
|
||
|
|
|
||
|
|
if (command.NewParentId == command.DocumentId)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("A document cannot be moved into itself");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (command.NewParentId is { } parentId)
|
||
|
|
{
|
||
|
|
var parent = await uow.Documents.GetByIdAsync(parentId, ct)
|
||
|
|
?? throw new NotFoundException("Parent folder not found");
|
||
|
|
if (parent.ProjectId != doc.ProjectId)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Parent must belong to the same project");
|
||
|
|
}
|
||
|
|
if (parent.Type != DocumentType.Folder)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Parent must be a folder");
|
||
|
|
}
|
||
|
|
|
||
|
|
var cursor = parent.ParentId;
|
||
|
|
while (cursor is not null)
|
||
|
|
{
|
||
|
|
if (cursor == command.DocumentId)
|
||
|
|
{
|
||
|
|
throw new BadRequestException("A folder cannot be moved into its own descendant");
|
||
|
|
}
|
||
|
|
cursor = await uow.Documents.GetParentIdAsync(cursor.Value, ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
doc.ParentId = command.NewParentId;
|
||
|
|
doc.UpdatedAt = DateTime.UtcNow;
|
||
|
|
doc.UpdatedBy = command.UserId;
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Create the DeleteDocument command**
|
||
|
|
|
||
|
|
Create `mws.application/Documents/Commands/DeleteDocument.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Permissions;
|
||
|
|
|
||
|
|
namespace Mws.Application.Documents;
|
||
|
|
|
||
|
|
public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest;
|
||
|
|
|
||
|
|
public class DeleteDocumentHandler(IUnitOfWork uow) : IRequestHandler<DeleteDocumentCommand>
|
||
|
|
{
|
||
|
|
public async Task Handle(DeleteDocumentCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
|
||
|
|
await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Delete, ct);
|
||
|
|
|
||
|
|
await DocumentAccess.DeleteDescendantsAsync(uow, command.DocumentId, ct);
|
||
|
|
uow.Documents.Remove(doc);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Delete the old service files**
|
||
|
|
|
||
|
|
Delete `mws.application/Documents/IDocumentService.cs` and `mws.application/Documents/DocumentService.cs`.
|
||
|
|
|
||
|
|
- [ ] **Step 8: Update `DocumentsController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/DocumentsController.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Documents;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api")]
|
||
|
|
[Authorize]
|
||
|
|
public class DocumentsController(ISender sender) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpGet("documents/search")]
|
||
|
|
public async Task<ActionResult<List<DocumentNodeDto>>> Search([FromQuery] string? q, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new SearchDocumentsQuery(User.GetUserId(), q ?? string.Empty), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("projects/{projectId:guid}/documents")]
|
||
|
|
public async Task<ActionResult<List<DocumentNodeDto>>> GetTree(Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetDocumentTreeQuery(User.GetUserId(), projectId), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost("projects/{projectId:guid}/documents")]
|
||
|
|
public async Task<ActionResult<DocumentDto>> Create(Guid projectId, [FromBody] CreateDocumentRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var result = await sender.Send(new CreateDocumentCommand(User.GetUserId(), projectId, request), ct);
|
||
|
|
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("documents/{id:guid}")]
|
||
|
|
public async Task<ActionResult<DocumentDto>> Get(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("documents/{id:guid}")]
|
||
|
|
public async Task<ActionResult<DocumentDto>> Update(Guid id, [FromBody] UpdateDocumentRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new UpdateDocumentCommand(User.GetUserId(), id, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("documents/{id:guid}/move")]
|
||
|
|
public async Task<ActionResult<DocumentDto>> Move(Guid id, [FromBody] MoveDocumentRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new MoveDocumentCommand(User.GetUserId(), id, request.NewParentId), ct);
|
||
|
|
return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpDelete("documents/{id:guid}")]
|
||
|
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new DeleteDocumentCommand(User.GetUserId(), id), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 9: Remove the old DI registration**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped<IDocumentService, DocumentService>();`.
|
||
|
|
|
||
|
|
- [ ] **Step 10: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 11: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/Documents mws.api/Controllers/DocumentsController.cs mws.infrastructure/DependencyInjection.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert Documents module to MediatR commands/queries
|
||
|
|
|
||
|
|
Replaces IDocumentService/DocumentService. The permission-check and
|
||
|
|
descendant-deletion logic moves to a shared DocumentAccess static
|
||
|
|
helper used by all seven handlers.
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Convert Tasks module
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `mws.application/Tasks/TaskAccess.cs`
|
||
|
|
- Create: `mws.application/Tasks/Queries/GetTasks.cs`
|
||
|
|
- Create: `mws.application/Tasks/Queries/GetTask.cs`
|
||
|
|
- Create: `mws.application/Tasks/Queries/SearchTasks.cs`
|
||
|
|
- Create: `mws.application/Tasks/Commands/CreateTask.cs`
|
||
|
|
- Create: `mws.application/Tasks/Commands/UpdateTask.cs`
|
||
|
|
- Create: `mws.application/Tasks/Commands/DeleteTask.cs`
|
||
|
|
- Delete: `mws.application/Tasks/ITaskService.cs`
|
||
|
|
- Delete: `mws.application/Tasks/TaskService.cs`
|
||
|
|
- Modify: `mws.api/Controllers/TasksController.cs`
|
||
|
|
- Modify: `mws.infrastructure/DependencyInjection.cs` (remove `AddScoped<ITaskService, TaskService>`)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `TaskAccess.GetDtoAsync(uow, mapper, id, ct) : Task<TaskDto>`, `TaskAccess.GetTaskForUserAsync(uow, userId, taskId, ct) : Task<TaskItem>`, `TaskAccess.EnsureMemberAccessAsync(uow, userId, projectId, ct)` — shared across the handlers below.
|
||
|
|
- Produces: `GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId) : IRequest<List<TaskDto>>`, `GetTaskQuery(Guid UserId, Guid TaskId) : IRequest<TaskDto>`, `SearchTasksQuery(Guid UserId, string Term) : IRequest<List<TaskDto>>`, `CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>`, `UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>`, `DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the shared task-access helper**
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/TaskAccess.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Tasks;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
internal static class TaskAccess
|
||
|
|
{
|
||
|
|
public static async Task<TaskDto> GetDtoAsync(IUnitOfWork uow, IMapper mapper, Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var task = await uow.Tasks.GetWithAssigneeAsync(id, ct)
|
||
|
|
?? throw new NotFoundException("Task not found");
|
||
|
|
return mapper.Map<TaskDto>(task);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task<TaskItem> GetTaskForUserAsync(IUnitOfWork uow, Guid userId, Guid taskId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var task = await uow.Tasks.GetByIdAsync(taskId, ct)
|
||
|
|
?? throw new NotFoundException("Task not found");
|
||
|
|
|
||
|
|
if (!await uow.Projects.IsMemberAsync(task.ProjectId, userId, ct))
|
||
|
|
{
|
||
|
|
throw new NotFoundException("Task not found");
|
||
|
|
}
|
||
|
|
return task;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static async Task EnsureMemberAccessAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct)
|
||
|
|
{
|
||
|
|
if (!await uow.Projects.IsMemberAsync(projectId, userId, ct))
|
||
|
|
{
|
||
|
|
throw new NotFoundException("Project not found");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the queries**
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/Queries/GetTasks.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
|
||
|
|
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
public record GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId)
|
||
|
|
: IRequest<List<TaskDto>>;
|
||
|
|
|
||
|
|
public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetTasksQuery, List<TaskDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<TaskDto>> Handle(GetTasksQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await TaskAccess.EnsureMemberAccessAsync(uow, query.UserId, query.ProjectId, ct);
|
||
|
|
|
||
|
|
var tasks = await uow.Tasks.GetForProjectAsync(query.ProjectId, query.Status, query.Priority, query.AssigneeId, ct);
|
||
|
|
return mapper.Map<List<TaskDto>>(tasks);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/Queries/GetTask.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
public record GetTaskQuery(Guid UserId, Guid TaskId) : IRequest<TaskDto>;
|
||
|
|
|
||
|
|
public class GetTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<GetTaskQuery, TaskDto>
|
||
|
|
{
|
||
|
|
public async Task<TaskDto> Handle(GetTaskQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var task = await TaskAccess.GetTaskForUserAsync(uow, query.UserId, query.TaskId, ct);
|
||
|
|
return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/Queries/SearchTasks.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
public record SearchTasksQuery(Guid UserId, string Term) : IRequest<List<TaskDto>>;
|
||
|
|
|
||
|
|
public class SearchTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<SearchTasksQuery, List<TaskDto>>
|
||
|
|
{
|
||
|
|
public async Task<List<TaskDto>> Handle(SearchTasksQuery query, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var projectIds = await uow.Projects.GetProjectIdsForUserAsync(query.UserId, ct);
|
||
|
|
var tasks = await uow.Tasks.SearchWithAssigneeAsync(projectIds, query.Term, 20, ct);
|
||
|
|
return mapper.Map<List<TaskDto>>(tasks);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the CreateTask command**
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/Commands/CreateTask.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Domain.Tasks;
|
||
|
|
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
|
||
|
|
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest<TaskDto>;
|
||
|
|
|
||
|
|
public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateTaskCommand, TaskDto>
|
||
|
|
{
|
||
|
|
public async Task<TaskDto> Handle(CreateTaskCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await TaskAccess.EnsureMemberAccessAsync(uow, command.UserId, command.ProjectId, ct);
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Task title is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(command.ProjectId, request.AssigneeId.Value, ct))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Assignee must be a member of the project");
|
||
|
|
}
|
||
|
|
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
var task = new TaskItem
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
ProjectId = command.ProjectId,
|
||
|
|
Title = request.Title.Trim(),
|
||
|
|
Description = request.Description,
|
||
|
|
Status = request.Status ?? TaskStatus.Todo,
|
||
|
|
Priority = request.Priority ?? TaskPriority.Medium,
|
||
|
|
AssigneeId = request.AssigneeId,
|
||
|
|
DueDate = request.DueDate,
|
||
|
|
CreatedBy = command.UserId,
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now,
|
||
|
|
};
|
||
|
|
|
||
|
|
uow.Tasks.Add(task);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create the UpdateTask command**
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/Commands/UpdateTask.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using AutoMapper;
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest<TaskDto>;
|
||
|
|
|
||
|
|
public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<UpdateTaskCommand, TaskDto>
|
||
|
|
{
|
||
|
|
public async Task<TaskDto> Handle(UpdateTaskCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct);
|
||
|
|
|
||
|
|
var request = command.Request;
|
||
|
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Task title is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(task.ProjectId, request.AssigneeId.Value, ct))
|
||
|
|
{
|
||
|
|
throw new BadRequestException("Assignee must be a member of the project");
|
||
|
|
}
|
||
|
|
|
||
|
|
task.Title = request.Title.Trim();
|
||
|
|
task.Description = request.Description;
|
||
|
|
task.Status = request.Status;
|
||
|
|
task.Priority = request.Priority;
|
||
|
|
task.AssigneeId = request.AssigneeId;
|
||
|
|
task.DueDate = request.DueDate;
|
||
|
|
task.UpdatedAt = DateTime.UtcNow;
|
||
|
|
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create the DeleteTask command**
|
||
|
|
|
||
|
|
Create `mws.application/Tasks/Commands/DeleteTask.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
|
||
|
|
namespace Mws.Application.Tasks;
|
||
|
|
|
||
|
|
public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest;
|
||
|
|
|
||
|
|
public class DeleteTaskHandler(IUnitOfWork uow) : IRequestHandler<DeleteTaskCommand>
|
||
|
|
{
|
||
|
|
public async Task Handle(DeleteTaskCommand command, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct);
|
||
|
|
uow.Tasks.Remove(task);
|
||
|
|
await uow.SaveChangesAsync(ct);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Delete the old service files**
|
||
|
|
|
||
|
|
Delete `mws.application/Tasks/ITaskService.cs` and `mws.application/Tasks/TaskService.cs`.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Update `TasksController`**
|
||
|
|
|
||
|
|
Replace the full contents of `mws.api/Controllers/TasksController.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using MediatR;
|
||
|
|
using Microsoft.AspNetCore.Authorization;
|
||
|
|
using Microsoft.AspNetCore.Mvc;
|
||
|
|
using Mws.Application.Common;
|
||
|
|
using Mws.Application.Tasks;
|
||
|
|
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
|
||
|
|
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
|
||
|
|
|
||
|
|
namespace Mws.Api.Controllers;
|
||
|
|
|
||
|
|
[ApiController]
|
||
|
|
[Route("api")]
|
||
|
|
[Authorize]
|
||
|
|
public class TasksController(ISender sender) : ControllerBase
|
||
|
|
{
|
||
|
|
[HttpGet("tasks/search")]
|
||
|
|
public async Task<ActionResult<List<TaskDto>>> Search([FromQuery] string? q, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new SearchTasksQuery(User.GetUserId(), q ?? string.Empty), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("projects/{projectId:guid}/tasks")]
|
||
|
|
public async Task<ActionResult<List<TaskDto>>> GetAll(
|
||
|
|
Guid projectId,
|
||
|
|
[FromQuery] string? status,
|
||
|
|
[FromQuery] string? priority,
|
||
|
|
[FromQuery] Guid? assigneeId,
|
||
|
|
CancellationToken ct)
|
||
|
|
{
|
||
|
|
var statusValue = ParseOptional<TaskStatus>(status);
|
||
|
|
var priorityValue = ParseOptional<TaskPriority>(priority);
|
||
|
|
return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPost("projects/{projectId:guid}/tasks")]
|
||
|
|
public async Task<ActionResult<TaskDto>> Create(Guid projectId, [FromBody] CreateTaskRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var result = await sender.Send(new CreateTaskCommand(User.GetUserId(), projectId, request), ct);
|
||
|
|
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpGet("tasks/{id:guid}")]
|
||
|
|
public async Task<ActionResult<TaskDto>> Get(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new GetTaskQuery(User.GetUserId(), id), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpPut("tasks/{id:guid}")]
|
||
|
|
public async Task<ActionResult<TaskDto>> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
return Ok(await sender.Send(new UpdateTaskCommand(User.GetUserId(), id, request), ct));
|
||
|
|
}
|
||
|
|
|
||
|
|
[HttpDelete("tasks/{id:guid}")]
|
||
|
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await sender.Send(new DeleteTaskCommand(User.GetUserId(), id), ct);
|
||
|
|
return NoContent();
|
||
|
|
}
|
||
|
|
|
||
|
|
private static TEnum? ParseOptional<TEnum>(string? value) where TEnum : struct, Enum
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(value))
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return Enum.TryParse<TEnum>(value, ignoreCase: true, out var result) ? result : throw new BadRequestException($"Invalid {typeof(TEnum).Name}: {value}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Remove the old DI registration**
|
||
|
|
|
||
|
|
In `mws.infrastructure/DependencyInjection.cs`, remove the line `services.AddScoped<ITaskService, TaskService>();`.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)`.
|
||
|
|
|
||
|
|
- [ ] **Step 10: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add mws.application/Tasks mws.api/Controllers/TasksController.cs mws.infrastructure/DependencyInjection.cs
|
||
|
|
git commit -m "$(cat <<'EOF'
|
||
|
|
Convert Tasks module to MediatR commands/queries
|
||
|
|
|
||
|
|
Replaces ITaskService/TaskService, completing the CQRS/mediator
|
||
|
|
refactor. All 9 old service interfaces are now gone except
|
||
|
|
IPermissionService, ITokenService, and IPasswordHasher, which stay
|
||
|
|
plain injected services by design (see spec).
|
||
|
|
EOF
|
||
|
|
)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: Final verification
|
||
|
|
|
||
|
|
**Files:** none (verification only).
|
||
|
|
|
||
|
|
**Interfaces:** none — this task exercises the full request pipeline end to end.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Confirm no old service interfaces remain**
|
||
|
|
|
||
|
|
Run: `grep -rl "IAuthService\|IAccountService\|IRoleService\|IProjectService\|IProjectMemberService\|IDocumentService\|ITaskService" mws.application mws.api mws.infrastructure`
|
||
|
|
Expected: no output (empty match). If anything matches, it's a leftover reference from an earlier task — fix it before continuing.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Full solution build**
|
||
|
|
|
||
|
|
Run: `dotnet build mws.backend.dotnet.sln`
|
||
|
|
Expected: `Build succeeded`, `0 Error(s)` (existing 2 nullable warnings in `Program.cs` are pre-existing and unrelated — fine to see those, not new ones).
|
||
|
|
|
||
|
|
- [ ] **Step 3: Start the API**
|
||
|
|
|
||
|
|
Run: `dotnet run --project mws.api` (in the background, or a separate terminal) — it auto-migrates and seeds on startup per `CLAUDE.md`. Wait for `Now listening on: http://localhost:5xxx` (check `mws.api/Properties/launchSettings.json` for the exact port).
|
||
|
|
|
||
|
|
- [ ] **Step 4: Smoke-test the command path — login**
|
||
|
|
|
||
|
|
Run: `curl -s -X POST http://localhost:<port>/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"password"}'`
|
||
|
|
Expected: JSON body with a `token` field and a `user` object — confirms `LoginCommand`/`LoginHandler` and the MediatR DI wiring both work.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Smoke-test a query + a command that goes through `IUnitOfWork` — projects**
|
||
|
|
|
||
|
|
Using the token from Step 4:
|
||
|
|
```bash
|
||
|
|
TOKEN="<paste token here>"
|
||
|
|
curl -s http://localhost:<port>/api/projects -H "Authorization: Bearer $TOKEN"
|
||
|
|
curl -s -X POST http://localhost:<port>/api/projects -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name":"CQRS smoke test"}'
|
||
|
|
```
|
||
|
|
Expected: first call returns the seeded demo project as a JSON array; second call returns 200 with the new project's DTO — confirms `GetProjectsQuery` and `CreateProjectCommand` both round-trip through EF Core correctly.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Smoke-test the untouched permission path — menu**
|
||
|
|
|
||
|
|
Run: `curl -s http://localhost:<port>/api/menu -H "Authorization: Bearer $TOKEN"`
|
||
|
|
Expected: JSON array of menu items with CRUD flags — confirms `IPermissionService` (left as a plain injected service) still resolves and works alongside the new MediatR-based handlers.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Stop the API**
|
||
|
|
|
||
|
|
Stop the `dotnet run` process (Ctrl-C, or kill the background job).
|
||
|
|
|
||
|
|
No commit for this task — it's verification only, nothing changed.
|