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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:03:16 +07:00
co-authored by Claude Sonnet 5
parent 798637c6c1
commit c3384d76d9
12 changed files with 318 additions and 255 deletions
@@ -0,0 +1,57 @@
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);
}
}
@@ -0,0 +1,20 @@
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);
}
}
@@ -0,0 +1,51 @@
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);
}
}
@@ -0,0 +1,40 @@
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);
}
}