230 lines
8.0 KiB
C#
230 lines
8.0 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|