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:
@@ -1,3 +1,4 @@
|
|||||||
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Mws.Application.Documents;
|
using Mws.Application.Documents;
|
||||||
@@ -7,50 +8,50 @@ namespace Mws.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api")]
|
[Route("api")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class DocumentsController(IDocumentService documentService) : ControllerBase
|
public class DocumentsController(ISender sender) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("documents/search")]
|
[HttpGet("documents/search")]
|
||||||
public async Task<ActionResult<List<DocumentNodeDto>>> Search([FromQuery] string? q, CancellationToken ct)
|
public async Task<ActionResult<List<DocumentNodeDto>>> Search([FromQuery] string? q, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Ok(await documentService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct));
|
return Ok(await sender.Send(new SearchDocumentsQuery(User.GetUserId(), q ?? string.Empty), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("projects/{projectId:guid}/documents")]
|
[HttpGet("projects/{projectId:guid}/documents")]
|
||||||
public async Task<ActionResult<List<DocumentNodeDto>>> GetTree(Guid projectId, CancellationToken ct)
|
public async Task<ActionResult<List<DocumentNodeDto>>> GetTree(Guid projectId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Ok(await documentService.GetTreeAsync(User.GetUserId(), projectId, ct));
|
return Ok(await sender.Send(new GetDocumentTreeQuery(User.GetUserId(), projectId), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("projects/{projectId:guid}/documents")]
|
[HttpPost("projects/{projectId:guid}/documents")]
|
||||||
public async Task<ActionResult<DocumentDto>> Create(Guid projectId, [FromBody] CreateDocumentRequest request, CancellationToken ct)
|
public async Task<ActionResult<DocumentDto>> Create(Guid projectId, [FromBody] CreateDocumentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await documentService.CreateAsync(User.GetUserId(), projectId, request, ct);
|
var result = await sender.Send(new CreateDocumentCommand(User.GetUserId(), projectId, request), ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
|
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("documents/{id:guid}")]
|
[HttpGet("documents/{id:guid}")]
|
||||||
public async Task<ActionResult<DocumentDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<DocumentDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Ok(await documentService.GetAsync(User.GetUserId(), id, ct));
|
return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("documents/{id:guid}")]
|
[HttpPut("documents/{id:guid}")]
|
||||||
public async Task<ActionResult<DocumentDto>> Update(Guid id, [FromBody] UpdateDocumentRequest request, CancellationToken ct)
|
public async Task<ActionResult<DocumentDto>> Update(Guid id, [FromBody] UpdateDocumentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Ok(await documentService.UpdateAsync(User.GetUserId(), id, request, ct));
|
return Ok(await sender.Send(new UpdateDocumentCommand(User.GetUserId(), id, request), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("documents/{id:guid}/move")]
|
[HttpPut("documents/{id:guid}/move")]
|
||||||
public async Task<ActionResult<DocumentDto>> Move(Guid id, [FromBody] MoveDocumentRequest request, CancellationToken ct)
|
public async Task<ActionResult<DocumentDto>> Move(Guid id, [FromBody] MoveDocumentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await documentService.MoveAsync(User.GetUserId(), id, request.NewParentId, ct);
|
await sender.Send(new MoveDocumentCommand(User.GetUserId(), id, request.NewParentId), ct);
|
||||||
return Ok(await documentService.GetAsync(User.GetUserId(), id, ct));
|
return Ok(await sender.Send(new GetDocumentQuery(User.GetUserId(), id), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("documents/{id:guid}")]
|
[HttpDelete("documents/{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await documentService.DeleteAsync(User.GetUserId(), id, ct);
|
await sender.Send(new DeleteDocumentCommand(User.GetUserId(), id), ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using Mws.Application.Common;
|
|
||||||
using Mws.Application.Permissions;
|
|
||||||
using Mws.Domain.Documents;
|
|
||||||
using Mws.Domain.Projects;
|
|
||||||
|
|
||||||
namespace Mws.Application.Documents;
|
|
||||||
|
|
||||||
public class DocumentService(IUnitOfWork uow, IMapper mapper) : IDocumentService
|
|
||||||
{
|
|
||||||
public async Task<List<DocumentNodeDto>> GetTreeAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await EnsureDocumentPermissionAsync(userId, projectId, PermissionAction.View, ct);
|
|
||||||
|
|
||||||
var docs = await uow.Documents.GetTreeForProjectAsync(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DocumentDto> GetAsync(Guid userId, Guid documentId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var doc = await GetDocumentForUserAsync(userId, documentId, ct);
|
|
||||||
return mapper.Map<DocumentDto>(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DocumentDto> CreateAsync(Guid userId, Guid projectId, CreateDocumentRequest request, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await EnsureDocumentPermissionAsync(userId, projectId, PermissionAction.Create, ct);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Title is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.ParentId is { } parentId)
|
|
||||||
{
|
|
||||||
var parent = await uow.Documents.GetInProjectAsync(parentId, 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 = projectId,
|
|
||||||
ParentId = request.ParentId,
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
Type = request.Type,
|
|
||||||
Content = request.Type == DocumentType.Document ? request.Content : null,
|
|
||||||
CreatedBy = userId,
|
|
||||||
CreatedAt = now,
|
|
||||||
UpdatedBy = userId,
|
|
||||||
UpdatedAt = now,
|
|
||||||
};
|
|
||||||
|
|
||||||
uow.Documents.Add(doc);
|
|
||||||
await uow.SaveChangesAsync(ct);
|
|
||||||
return mapper.Map<DocumentDto>(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DocumentDto> UpdateAsync(Guid userId, Guid documentId, UpdateDocumentRequest request, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var doc = await GetDocumentForUserAsync(userId, documentId, ct);
|
|
||||||
await EnsureDocumentPermissionAsync(userId, doc.ProjectId, PermissionAction.Edit, ct);
|
|
||||||
|
|
||||||
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 = userId;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
doc.UpdatedAt = DateTime.UtcNow;
|
|
||||||
doc.UpdatedBy = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await uow.SaveChangesAsync(ct);
|
|
||||||
return mapper.Map<DocumentDto>(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MoveAsync(Guid userId, Guid documentId, Guid? newParentId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var doc = await GetDocumentForUserAsync(userId, documentId, ct);
|
|
||||||
await EnsureDocumentPermissionAsync(userId, doc.ProjectId, PermissionAction.Edit, ct);
|
|
||||||
|
|
||||||
if (newParentId == documentId)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("A document cannot be moved into itself");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (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 == documentId)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("A folder cannot be moved into its own descendant");
|
|
||||||
}
|
|
||||||
cursor = await uow.Documents.GetParentIdAsync(cursor.Value, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.ParentId = newParentId;
|
|
||||||
doc.UpdatedAt = DateTime.UtcNow;
|
|
||||||
doc.UpdatedBy = userId;
|
|
||||||
await uow.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid userId, Guid documentId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var doc = await GetDocumentForUserAsync(userId, documentId, ct);
|
|
||||||
await EnsureDocumentPermissionAsync(userId, doc.ProjectId, PermissionAction.Delete, ct);
|
|
||||||
|
|
||||||
await DeleteDescendantsAsync(documentId, ct);
|
|
||||||
uow.Documents.Remove(doc);
|
|
||||||
await uow.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<DocumentNodeDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var projectIds = await uow.Projects.GetDocumentViewableProjectIdsAsync(userId, ct);
|
|
||||||
var results = await uow.Documents.SearchAsync(projectIds, term, 20, ct);
|
|
||||||
return mapper.Map<List<DocumentNodeDto>>(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteDescendantsAsync(Guid parentId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var children = await uow.Documents.GetChildrenAsync(parentId, ct);
|
|
||||||
foreach (var child in children)
|
|
||||||
{
|
|
||||||
await DeleteDescendantsAsync(child.Id, ct);
|
|
||||||
uow.Documents.Remove(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task EnsureDocumentPermissionAsync(
|
|
||||||
Guid userId, Guid projectId, PermissionAction action, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var member = await uow.Projects.GetMemberAsync(projectId, userId, ct)
|
|
||||||
?? throw new NotFoundException("Project not found");
|
|
||||||
|
|
||||||
if (!await HasDocumentPermissionAsync(member, action, ct))
|
|
||||||
{
|
|
||||||
throw new ForbiddenException("You do not have permission to access documents in this project");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> HasDocumentPermissionAsync(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Document> GetDocumentForUserAsync(Guid userId, Guid documentId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
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(member, PermissionAction.View, ct))
|
|
||||||
{
|
|
||||||
throw new ForbiddenException("You do not have permission to view this document");
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using Mws.Application.Common;
|
|
||||||
|
|
||||||
namespace Mws.Application.Documents;
|
|
||||||
|
|
||||||
public interface IDocumentService
|
|
||||||
{
|
|
||||||
Task<List<DocumentNodeDto>> GetTreeAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
|
||||||
Task<DocumentDto> GetAsync(Guid userId, Guid documentId, CancellationToken ct = default);
|
|
||||||
Task<DocumentDto> CreateAsync(Guid userId, Guid projectId, CreateDocumentRequest request, CancellationToken ct = default);
|
|
||||||
Task<DocumentDto> UpdateAsync(Guid userId, Guid documentId, UpdateDocumentRequest request, CancellationToken ct = default);
|
|
||||||
Task MoveAsync(Guid userId, Guid documentId, Guid? newParentId, CancellationToken ct = default);
|
|
||||||
Task DeleteAsync(Guid userId, Guid documentId, CancellationToken ct = default);
|
|
||||||
Task<List<DocumentNodeDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Mws.Application.Auth;
|
using Mws.Application.Auth;
|
||||||
using Mws.Application.Common;
|
using Mws.Application.Common;
|
||||||
using Mws.Application.Documents;
|
|
||||||
using Mws.Application.Permissions;
|
using Mws.Application.Permissions;
|
||||||
using Mws.Application.Projects;
|
using Mws.Application.Projects;
|
||||||
using Mws.Application.Tasks;
|
using Mws.Application.Tasks;
|
||||||
@@ -39,7 +38,6 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ITokenService, JwtTokenService>();
|
services.AddScoped<ITokenService, JwtTokenService>();
|
||||||
|
|
||||||
services.AddScoped<IPermissionService, PermissionService>();
|
services.AddScoped<IPermissionService, PermissionService>();
|
||||||
services.AddScoped<IDocumentService, DocumentService>();
|
|
||||||
services.AddScoped<ITaskService, TaskService>();
|
services.AddScoped<ITaskService, TaskService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
Reference in New Issue
Block a user