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> 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(d)); var roots = new List(); foreach (var node in nodes.Values) { if (node.ParentId is { } parentId && nodes.TryGetValue(parentId, out var parent)) { parent.Children.Add(node); } else { roots.Add(node); } } return roots; } public async Task GetAsync(Guid userId, Guid documentId, CancellationToken ct = default) { var doc = await GetDocumentForUserAsync(userId, documentId, ct); return mapper.Map(doc); } public async Task 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(doc); } public async Task 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(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> 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>(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 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 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; } }