Commit MWS backend source tree
The ASP.NET Core solution (mws.api/mws.application/mws.domain/ mws.infrastructure) existed only as untracked working files. Adding it to version control, plus a .gitignore for build output and local tooling directories, so the CQRS/mediator refactor plan has a real git history to branch and diff against.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
using AutoMapper;
|
||||
using Mws.Application.Auth;
|
||||
using Mws.Application.Common;
|
||||
using Mws.Application.Permissions;
|
||||
using Mws.Domain.Users;
|
||||
|
||||
namespace Mws.Application.Accounts;
|
||||
|
||||
public class AccountService(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) : IAccountService
|
||||
{
|
||||
private const string Screen = "accounts";
|
||||
|
||||
public async Task<List<AccountDto>> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default)
|
||||
{
|
||||
await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.View, ct);
|
||||
|
||||
var users = await uow.Users.SearchWithRoleAsync(term, null, ct);
|
||||
return mapper.Map<List<AccountDto>>(users);
|
||||
}
|
||||
|
||||
public async Task<AccountDto> CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Create, ct);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<AccountDto> UpdateAccountAsync(Guid actorUserId, Guid id, UpdateAccountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Edit, ct);
|
||||
|
||||
var user = await uow.Users.GetByIdWithRoleAsync(id, ct)
|
||||
?? throw new NotFoundException("Account not found");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task DeleteAccountAsync(Guid actorUserId, Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Delete, ct);
|
||||
|
||||
var user = await uow.Users.GetByIdAsync(id, ct)
|
||||
?? throw new NotFoundException("Account not found");
|
||||
|
||||
var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(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);
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(Guid actorUserId, Guid id, ResetPasswordRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Edit, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
{
|
||||
throw new BadRequestException("New password is required");
|
||||
}
|
||||
|
||||
var user = await uow.Users.GetByIdAsync(id, ct)
|
||||
?? throw new NotFoundException("Account not found");
|
||||
|
||||
user.PasswordHash = passwordHasher.Hash(request.NewPassword);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await uow.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Mws.Application.Accounts;
|
||||
|
||||
public class CreateAccountRequest
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public Guid RoleId { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateAccountRequest
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public Guid RoleId { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class ResetPasswordRequest
|
||||
{
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AccountDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public Guid RoleId { get; set; }
|
||||
public string RoleName { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Mws.Application.Accounts;
|
||||
|
||||
public interface IAccountService
|
||||
{
|
||||
Task<List<AccountDto>> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default);
|
||||
Task<AccountDto> CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default);
|
||||
Task<AccountDto> UpdateAccountAsync(Guid actorUserId, Guid id, UpdateAccountRequest request, CancellationToken ct = default);
|
||||
Task DeleteAccountAsync(Guid actorUserId, Guid id, CancellationToken ct = default);
|
||||
Task ResetPasswordAsync(Guid actorUserId, Guid id, ResetPasswordRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using AutoMapper;
|
||||
using Mws.Application.Common;
|
||||
|
||||
namespace Mws.Application.Auth;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class AuthService(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper)
|
||||
: IAuthService
|
||||
{
|
||||
public async Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
var user = await uow.Users.GetByUsernameWithRoleAsync(username, cancellationToken);
|
||||
|
||||
if (user is null || !passwordHasher.Verify(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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Mws.Application.Auth;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UserDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public Guid RoleId { get; set; }
|
||||
public string RoleName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LoginResponse
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public UserDto User { get; set; } = null!;
|
||||
}
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string hash);
|
||||
}
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string CreateToken(Guid userId, string username);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Mws.Application.Common;
|
||||
|
||||
public class NotFoundException(string message) : Exception(message);
|
||||
|
||||
public class ForbiddenException(string message) : Exception(message);
|
||||
|
||||
public class UnauthorizedException(string message) : Exception(message);
|
||||
|
||||
public class BadRequestException(string message) : Exception(message);
|
||||
@@ -0,0 +1,14 @@
|
||||
using Mws.Application.Common.Repositories;
|
||||
|
||||
namespace Mws.Application.Common;
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
IUserRepository Users { get; }
|
||||
IProjectRepository Projects { get; }
|
||||
ITaskRepository Tasks { get; }
|
||||
IDocumentRepository Documents { get; }
|
||||
IRoleRepository Roles { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutoMapper;
|
||||
using Mws.Application.Accounts;
|
||||
using Mws.Application.Auth;
|
||||
using Mws.Application.Documents;
|
||||
using Mws.Application.Projects;
|
||||
using Mws.Application.Roles;
|
||||
using Mws.Application.Tasks;
|
||||
using Mws.Domain.Documents;
|
||||
using Mws.Domain.Roles;
|
||||
using Mws.Domain.Tasks;
|
||||
using Mws.Domain.Users;
|
||||
|
||||
namespace Mws.Application.Common;
|
||||
|
||||
public class MappingProfile : Profile
|
||||
{
|
||||
public MappingProfile()
|
||||
{
|
||||
CreateMap<Domain.Projects.Project, ProjectDto>();
|
||||
CreateMap<Role, RoleDto>();
|
||||
CreateMap<RolePermission, PermissionEntryDto>();
|
||||
CreateMap<User, AccountDto>();
|
||||
CreateMap<User, UserDto>();
|
||||
CreateMap<Document, DocumentDto>();
|
||||
CreateMap<Document, DocumentNodeDto>()
|
||||
.ForMember(d => d.Children, opt => opt.Ignore());
|
||||
CreateMap<TaskItem, TaskDto>()
|
||||
.ForMember(d => d.AssigneeName, opt => opt.MapFrom(s => s.Assignee == null ? null : s.Assignee.DisplayName));
|
||||
CreateMap<TaskItem, RecentTaskDto>()
|
||||
.ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString()));
|
||||
CreateMap<Document, RecentDocumentDto>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Mws.Domain.Documents;
|
||||
|
||||
namespace Mws.Application.Common.Repositories;
|
||||
|
||||
public interface IDocumentRepository : IRepository<Document>
|
||||
{
|
||||
Task<Document?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<Document>> GetTreeForProjectAsync(Guid projectId, CancellationToken ct = default);
|
||||
Task<Document?> GetInProjectAsync(Guid parentId, Guid projectId, CancellationToken ct = default);
|
||||
Task<Guid?> GetParentIdAsync(Guid documentId, CancellationToken ct = default);
|
||||
Task<List<Document>> GetChildrenAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<Document>> SearchAsync(List<Guid> projectIds, string? term, int take, CancellationToken ct = default);
|
||||
Task<int> CountByTypeForProjectAsync(Guid projectId, DocumentType type, CancellationToken ct = default);
|
||||
Task<List<Document>> GetRecentForProjectAsync(Guid projectId, DocumentType type, int take, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Mws.Domain.Projects;
|
||||
|
||||
namespace Mws.Application.Common.Repositories;
|
||||
|
||||
public interface IProjectRepository : IRepository<Project>
|
||||
{
|
||||
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<Project?> GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
||||
Task<List<Project>> GetForUserAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<List<Project>> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default);
|
||||
Task<int> CountMembersAsync(Guid projectId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> IsMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||
Task<MemberRole?> GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||
Task<ProjectMember?> GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||
Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default);
|
||||
Task<List<ProjectMember>> GetMembersWithUserAsync(Guid projectId, CancellationToken ct = default);
|
||||
Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default);
|
||||
Task<List<Guid>> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<List<Guid>> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<List<Guid>> GetDocumentViewableProjectIdsAsync(Guid userId, CancellationToken ct = default);
|
||||
void AddMember(ProjectMember member);
|
||||
void RemoveMember(ProjectMember member);
|
||||
|
||||
Task<ProjectMemberPermission?> GetMemberPermissionAsync(Guid projectId, Guid userId, string screen, CancellationToken ct = default);
|
||||
Task<Dictionary<Guid, ProjectMemberPermission>> GetMemberPermissionsAsync(Guid projectId, string screen, CancellationToken ct = default);
|
||||
void AddMemberPermission(ProjectMemberPermission permission);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Mws.Application.Common.Repositories;
|
||||
|
||||
public interface IRepository<in T> where T : class
|
||||
{
|
||||
void Add(T entity);
|
||||
void Remove(T entity);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Mws.Domain.Roles;
|
||||
|
||||
namespace Mws.Application.Common.Repositories;
|
||||
|
||||
public interface IRoleRepository : IRepository<Role>
|
||||
{
|
||||
Task<List<Role>> GetAllWithPermissionsAsync(CancellationToken ct = default);
|
||||
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default);
|
||||
Task<Role?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<bool> ExistsByNameAsync(string name, Guid? excludeId, CancellationToken ct = default);
|
||||
Task<RolePermission?> GetPermissionAsync(Guid roleId, string screen, CancellationToken ct = default);
|
||||
Task<Dictionary<string, RolePermission>> GetPermissionsAsync(Guid roleId, CancellationToken ct = default);
|
||||
void RemovePermissions(IEnumerable<RolePermission> permissions);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Mws.Domain.Tasks;
|
||||
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
|
||||
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
|
||||
|
||||
namespace Mws.Application.Common.Repositories;
|
||||
|
||||
public interface ITaskRepository : IRepository<TaskItem>
|
||||
{
|
||||
Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskItem?> GetWithAssigneeAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<TaskItem>> GetForProjectAsync(
|
||||
Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default);
|
||||
Task<List<TaskItem>> SearchWithAssigneeAsync(List<Guid> projectIds, string? term, int take, CancellationToken ct = default);
|
||||
Task<Dictionary<TaskStatus, int>> CountByStatusForProjectAsync(Guid projectId, CancellationToken ct = default);
|
||||
Task<List<TaskItem>> GetRecentForProjectAsync(Guid projectId, int take, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Mws.Domain.Users;
|
||||
|
||||
namespace Mws.Application.Common.Repositories;
|
||||
|
||||
public interface IUserRepository : IRepository<User>
|
||||
{
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<User?> GetByIdWithRoleAsync(Guid id, CancellationToken ct = default);
|
||||
Task<User?> GetByUsernameWithRoleAsync(string username, CancellationToken ct = default);
|
||||
Task<bool> ExistsByUsernameAsync(string username, CancellationToken ct = default);
|
||||
Task<bool> ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default);
|
||||
Task<Guid> GetRoleIdAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<List<User>> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Mws.Domain.Documents;
|
||||
|
||||
namespace Mws.Application.Documents;
|
||||
|
||||
public class CreateDocumentRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DocumentType Type { get; set; } = DocumentType.Document;
|
||||
public Guid? ParentId { get; set; }
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateDocumentRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
public class MoveDocumentRequest
|
||||
{
|
||||
public Guid? NewParentId { get; set; }
|
||||
}
|
||||
|
||||
public class DocumentDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public Guid? ParentId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Content { get; set; }
|
||||
public DocumentType Type { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public Guid? UpdatedBy { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class DocumentNodeDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid? ParentId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DocumentType Type { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public Guid? UpdatedBy { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public List<DocumentNodeDto> Children { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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,20 @@
|
||||
namespace Mws.Application.Permissions;
|
||||
|
||||
public enum PermissionAction
|
||||
{
|
||||
View,
|
||||
Create,
|
||||
Edit,
|
||||
Delete,
|
||||
}
|
||||
|
||||
public class MenuItemDto
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public bool CanView { get; set; }
|
||||
public bool CanCreate { get; set; }
|
||||
public bool CanEdit { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Mws.Application.Permissions;
|
||||
|
||||
public interface IPermissionService
|
||||
{
|
||||
Task<List<MenuItemDto>> GetMenuAsync(Guid userId, CancellationToken ct = default);
|
||||
Task EnsureAsync(Guid userId, string screen, PermissionAction action, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Mws.Application.Common;
|
||||
|
||||
namespace Mws.Application.Permissions;
|
||||
|
||||
public class PermissionService(IUnitOfWork uow) : IPermissionService
|
||||
{
|
||||
public async Task<List<MenuItemDto>> GetMenuAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var roleId = await uow.Users.GetRoleIdAsync(userId, ct);
|
||||
var permissions = await uow.Roles.GetPermissionsAsync(roleId, ct);
|
||||
|
||||
return ScreenCatalog.Screens.Select(s =>
|
||||
{
|
||||
permissions.TryGetValue(s.Key, out var p);
|
||||
return new MenuItemDto
|
||||
{
|
||||
Key = s.Key,
|
||||
Label = s.Label,
|
||||
Path = s.Path,
|
||||
CanView = p?.CanView ?? false,
|
||||
CanCreate = p?.CanCreate ?? false,
|
||||
CanEdit = p?.CanEdit ?? false,
|
||||
CanDelete = p?.CanDelete ?? false,
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task EnsureAsync(Guid userId, string screen, PermissionAction action, CancellationToken ct = default)
|
||||
{
|
||||
var roleId = await uow.Users.GetRoleIdAsync(userId, ct);
|
||||
var permission = await uow.Roles.GetPermissionAsync(roleId, screen, ct);
|
||||
|
||||
var allowed = action switch
|
||||
{
|
||||
PermissionAction.View => permission?.CanView ?? false,
|
||||
PermissionAction.Create => permission?.CanCreate ?? false,
|
||||
PermissionAction.Edit => permission?.CanEdit ?? false,
|
||||
PermissionAction.Delete => permission?.CanDelete ?? false,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
throw new ForbiddenException($"Not permitted to {action} on {screen}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Mws.Application.Permissions;
|
||||
|
||||
public record ScreenDefinition(string Key, string Label, string Path);
|
||||
|
||||
public static class ScreenCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<ScreenDefinition> Screens =
|
||||
[
|
||||
new("dashboard", "Dashboard", "/"),
|
||||
new("projects", "Projects", "/projects"),
|
||||
new("tasks", "Tasks", "/tasks"),
|
||||
new("documents", "Documents", "/documents"),
|
||||
new("accounts", "Accounts", "/accounts"),
|
||||
new("roles", "Roles", "/roles"),
|
||||
];
|
||||
|
||||
public static readonly IReadOnlySet<string> Keys = Screens.Select(s => s.Key).ToHashSet();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Mws.Domain.Projects;
|
||||
|
||||
namespace Mws.Application.Projects;
|
||||
|
||||
public class CreateProjectRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProjectRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public ProjectStatus Status { get; set; } = ProjectStatus.Active;
|
||||
}
|
||||
|
||||
public class AddMemberRequest
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public MemberRole Role { get; set; } = MemberRole.Member;
|
||||
}
|
||||
|
||||
public class ProjectMemberDto
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public MemberRole Role { get; set; }
|
||||
public bool CanViewDocuments { get; set; }
|
||||
public bool CanCreateDocuments { get; set; }
|
||||
public bool CanEditDocuments { get; set; }
|
||||
public bool CanDeleteDocuments { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateMemberDocumentPermissionsRequest
|
||||
{
|
||||
public bool CanViewDocuments { get; set; }
|
||||
public bool CanCreateDocuments { get; set; }
|
||||
public bool CanEditDocuments { get; set; }
|
||||
public bool CanDeleteDocuments { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public ProjectStatus Status { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectOverviewDto
|
||||
{
|
||||
public ProjectDto Project { get; set; } = null!;
|
||||
public int MemberCount { get; set; }
|
||||
public int DocumentCount { get; set; }
|
||||
public Dictionary<string, int> TaskCountsByStatus { get; set; } = [];
|
||||
public List<RecentTaskDto> RecentTasks { get; set; } = [];
|
||||
public List<RecentDocumentDto> RecentDocuments { get; set; } = [];
|
||||
}
|
||||
|
||||
public class RecentTaskDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class RecentDocumentDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Mws.Application.Common;
|
||||
|
||||
namespace Mws.Application.Projects;
|
||||
|
||||
public interface IProjectService
|
||||
{
|
||||
Task<List<ProjectDto>> GetProjectsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<ProjectDto> GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
||||
Task<ProjectDto> CreateProjectAsync(Guid userId, CreateProjectRequest request, CancellationToken ct = default);
|
||||
Task<ProjectDto> UpdateProjectAsync(Guid userId, Guid projectId, UpdateProjectRequest request, CancellationToken ct = default);
|
||||
Task ArchiveProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
||||
Task<ProjectOverviewDto> GetOverviewAsync(Guid userId, Guid projectId, CancellationToken ct = default);
|
||||
Task<List<ProjectDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Mws.Application.Common;
|
||||
using Mws.Domain.Projects;
|
||||
|
||||
namespace Mws.Application.Projects;
|
||||
|
||||
public class ProjectMemberService(IUnitOfWork uow) : IProjectMemberService
|
||||
{
|
||||
public async Task<List<ProjectMemberDto>> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var isMember = await uow.Projects.IsMemberAsync(projectId, userId, ct);
|
||||
if (!isMember)
|
||||
{
|
||||
throw new NotFoundException("Project not found");
|
||||
}
|
||||
|
||||
var members = await uow.Projects.GetMembersWithUserAsync(projectId, ct);
|
||||
var permissions = await uow.Projects.GetMemberPermissionsAsync(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();
|
||||
}
|
||||
|
||||
public async Task<ProjectMemberDto> AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner)
|
||||
{
|
||||
throw new ForbiddenException("Only the project owner can add members");
|
||||
}
|
||||
|
||||
var user = await uow.Users.GetByIdAsync(request.UserId, ct)
|
||||
?? throw new NotFoundException("User not found");
|
||||
|
||||
var already = await uow.Projects.IsMemberAsync(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 = projectId,
|
||||
UserId = request.UserId,
|
||||
Role = role,
|
||||
};
|
||||
|
||||
uow.Projects.AddMember(member);
|
||||
uow.Projects.AddMemberPermission(new ProjectMemberPermission
|
||||
{
|
||||
ProjectId = 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,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProjectMemberDto> UpdateMemberDocumentPermissionsAsync(
|
||||
Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner)
|
||||
{
|
||||
throw new ForbiddenException("Only the project owner can change member permissions");
|
||||
}
|
||||
|
||||
var member = await uow.Projects.GetMemberWithUserAsync(projectId, 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(projectId, memberUserId, ProjectPermissionScreens.Documents, ct);
|
||||
if (permission is null)
|
||||
{
|
||||
permission = new ProjectMemberPermission { ProjectId = projectId, UserId = memberUserId, Screen = ProjectPermissionScreens.Documents };
|
||||
uow.Projects.AddMemberPermission(permission);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (await GetMemberRoleAsync(userId, projectId, ct) != MemberRole.Owner)
|
||||
{
|
||||
throw new ForbiddenException("Only the project owner can remove members");
|
||||
}
|
||||
|
||||
var member = await uow.Projects.GetMemberAsync(projectId, memberUserId, ct)
|
||||
?? throw new NotFoundException("Member not found in project");
|
||||
|
||||
var owners = await uow.Projects.CountOwnersAsync(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);
|
||||
}
|
||||
|
||||
private Task<MemberRole?> GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
|
||||
uow.Projects.GetMemberRoleAsync(projectId, userId, ct);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using AutoMapper;
|
||||
using Mws.Application.Common;
|
||||
using Mws.Domain.Documents;
|
||||
using Mws.Domain.Projects;
|
||||
|
||||
namespace Mws.Application.Projects;
|
||||
|
||||
public class ProjectService(IUnitOfWork uow, IMapper mapper) : IProjectService
|
||||
{
|
||||
public async Task<List<ProjectDto>> GetProjectsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var projects = await uow.Projects.GetForUserAsync(userId, ct);
|
||||
return mapper.Map<List<ProjectDto>>(projects);
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var project = await GetProjectForUserAsync(userId, projectId, ct);
|
||||
return mapper.Map<ProjectDto>(project);
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> CreateProjectAsync(Guid userId, CreateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
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 = userId,
|
||||
Role = MemberRole.Owner,
|
||||
});
|
||||
|
||||
uow.Projects.Add(project);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
return mapper.Map<ProjectDto>(project);
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> UpdateProjectAsync(Guid userId, Guid projectId, UpdateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var project = await GetProjectForUserAsync(userId, projectId, ct);
|
||||
|
||||
if (MemberRole.Owner != await GetMemberRoleAsync(userId, projectId, ct))
|
||||
{
|
||||
throw new ForbiddenException("Only the project owner can update the project");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task ArchiveProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var project = await GetProjectForUserAsync(userId, projectId, ct);
|
||||
|
||||
if (await GetMemberRoleAsync(userId, projectId, 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);
|
||||
}
|
||||
|
||||
public async Task<ProjectOverviewDto> GetOverviewAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var project = await GetProjectForUserAsync(userId, projectId, ct);
|
||||
|
||||
var memberCount = await uow.Projects.CountMembersAsync(projectId, ct);
|
||||
var documentCount = await uow.Documents.CountByTypeForProjectAsync(projectId, DocumentType.Document, ct);
|
||||
|
||||
var taskCounts = await uow.Tasks.CountByStatusForProjectAsync(projectId, ct);
|
||||
var taskCountsByStatus = taskCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value);
|
||||
|
||||
var recentTasks = await uow.Tasks.GetRecentForProjectAsync(projectId, 5, ct);
|
||||
var recentDocuments = await uow.Documents.GetRecentForProjectAsync(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),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<ProjectDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default)
|
||||
{
|
||||
var projects = await uow.Projects.SearchForUserAsync(userId, term, ct);
|
||||
return mapper.Map<List<ProjectDto>>(projects);
|
||||
}
|
||||
|
||||
protected async Task<Project> GetProjectForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await uow.Projects.GetForUserAsync(userId, projectId, ct)
|
||||
?? throw new NotFoundException("Project not found");
|
||||
}
|
||||
|
||||
protected Task<MemberRole?> GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
|
||||
uow.Projects.GetMemberRoleAsync(projectId, userId, ct);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Mws.Application.Roles;
|
||||
|
||||
public class PermissionEntryDto
|
||||
{
|
||||
public string Screen { get; set; } = string.Empty;
|
||||
public bool CanView { get; set; }
|
||||
public bool CanCreate { get; set; }
|
||||
public bool CanEdit { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
}
|
||||
|
||||
public class RoleDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool IsSystem { get; set; }
|
||||
public List<PermissionEntryDto> Permissions { get; set; } = [];
|
||||
}
|
||||
|
||||
public class SaveRoleRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public List<PermissionEntryDto> Permissions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Mws.Application.Roles;
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<List<RoleDto>> GetRolesAsync(CancellationToken ct = default);
|
||||
Task<RoleDto> GetRoleAsync(Guid id, CancellationToken ct = default);
|
||||
Task<RoleDto> CreateRoleAsync(SaveRoleRequest request, CancellationToken ct = default);
|
||||
Task<RoleDto> UpdateRoleAsync(Guid id, SaveRoleRequest request, CancellationToken ct = default);
|
||||
Task DeleteRoleAsync(Guid id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using AutoMapper;
|
||||
using Mws.Application.Common;
|
||||
using Mws.Application.Permissions;
|
||||
using Mws.Domain.Roles;
|
||||
|
||||
namespace Mws.Application.Roles;
|
||||
|
||||
public class RoleService(IUnitOfWork uow, IMapper mapper) : IRoleService
|
||||
{
|
||||
public async Task<List<RoleDto>> GetRolesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var roles = await uow.Roles.GetAllWithPermissionsAsync(ct);
|
||||
return mapper.Map<List<RoleDto>>(roles);
|
||||
}
|
||||
|
||||
public async Task<RoleDto> GetRoleAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var role = await uow.Roles.GetByIdWithPermissionsAsync(id, ct)
|
||||
?? throw new NotFoundException("Role not found");
|
||||
return mapper.Map<RoleDto>(role);
|
||||
}
|
||||
|
||||
public async Task<RoleDto> CreateRoleAsync(SaveRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = 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 = BuildPermissions(request.Permissions),
|
||||
};
|
||||
|
||||
uow.Roles.Add(role);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
return mapper.Map<RoleDto>(role);
|
||||
}
|
||||
|
||||
public async Task<RoleDto> UpdateRoleAsync(Guid id, SaveRoleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var role = await uow.Roles.GetByIdWithPermissionsAsync(id, ct)
|
||||
?? throw new NotFoundException("Role not found");
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new BadRequestException("Role name is required");
|
||||
}
|
||||
|
||||
if (await uow.Roles.ExistsByNameAsync(name, id, ct))
|
||||
{
|
||||
throw new BadRequestException("Role name already exists");
|
||||
}
|
||||
|
||||
role.Name = name;
|
||||
role.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
uow.Roles.RemovePermissions(role.Permissions.ToList());
|
||||
role.Permissions = BuildPermissions(request.Permissions);
|
||||
foreach (var p in role.Permissions)
|
||||
{
|
||||
p.RoleId = role.Id;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync(ct);
|
||||
return mapper.Map<RoleDto>(role);
|
||||
}
|
||||
|
||||
public async Task DeleteRoleAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var role = await uow.Roles.GetByIdAsync(id, ct)
|
||||
?? throw new NotFoundException("Role not found");
|
||||
|
||||
if (role.IsSystem)
|
||||
{
|
||||
throw new BadRequestException("Cannot delete a system role");
|
||||
}
|
||||
|
||||
if (await uow.Users.ExistsByRoleIdAsync(id, ct))
|
||||
{
|
||||
throw new BadRequestException("Cannot delete a role that is assigned to accounts");
|
||||
}
|
||||
|
||||
uow.Roles.Remove(role);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static List<RolePermission> BuildPermissions(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Mws.Domain.Tasks;
|
||||
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
|
||||
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
|
||||
|
||||
namespace Mws.Application.Tasks;
|
||||
|
||||
public class CreateTaskRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public TaskStatus? Status { get; set; }
|
||||
public TaskPriority? Priority { get; set; }
|
||||
public Guid? AssigneeId { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateTaskRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public TaskStatus Status { get; set; } = TaskStatus.Todo;
|
||||
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
|
||||
public Guid? AssigneeId { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
}
|
||||
|
||||
public class TaskDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public TaskStatus Status { get; set; }
|
||||
public TaskPriority Priority { get; set; }
|
||||
public Guid? AssigneeId { get; set; }
|
||||
public string? AssigneeName { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Mws.Application.Common;
|
||||
using TaskPriority = Mws.Domain.Tasks.TaskPriority;
|
||||
using TaskStatus = Mws.Domain.Tasks.TaskStatus;
|
||||
|
||||
namespace Mws.Application.Tasks;
|
||||
|
||||
public interface ITaskService
|
||||
{
|
||||
Task<List<TaskDto>> GetTasksAsync(Guid userId, Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default);
|
||||
Task<TaskDto> GetAsync(Guid userId, Guid taskId, CancellationToken ct = default);
|
||||
Task<TaskDto> CreateAsync(Guid userId, Guid projectId, CreateTaskRequest request, CancellationToken ct = default);
|
||||
Task<TaskDto> UpdateAsync(Guid userId, Guid taskId, UpdateTaskRequest request, CancellationToken ct = default);
|
||||
Task DeleteAsync(Guid userId, Guid taskId, CancellationToken ct = default);
|
||||
Task<List<TaskDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using AutoMapper;
|
||||
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 class TaskService(IUnitOfWork uow, IMapper mapper) : ITaskService
|
||||
{
|
||||
public async Task<List<TaskDto>> GetTasksAsync(Guid userId, Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureMemberAccessAsync(userId, projectId, ct);
|
||||
|
||||
var tasks = await uow.Tasks.GetForProjectAsync(projectId, status, priority, assigneeId, ct);
|
||||
return mapper.Map<List<TaskDto>>(tasks);
|
||||
}
|
||||
|
||||
public async Task<TaskDto> GetAsync(Guid userId, Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var task = await GetTaskForUserAsync(userId, taskId, ct);
|
||||
return await GetDtoAsync(task.Id, ct);
|
||||
}
|
||||
|
||||
public async Task<TaskDto> CreateAsync(Guid userId, Guid projectId, CreateTaskRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureMemberAccessAsync(userId, projectId, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
throw new BadRequestException("Task title is required");
|
||||
}
|
||||
|
||||
if (request.AssigneeId.HasValue && !await IsMemberAsync(request.AssigneeId.Value, projectId, ct))
|
||||
{
|
||||
throw new BadRequestException("Assignee must be a member of the project");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = 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 = userId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
uow.Tasks.Add(task);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
return await GetDtoAsync(task.Id, ct);
|
||||
}
|
||||
|
||||
public async Task<TaskDto> UpdateAsync(Guid userId, Guid taskId, UpdateTaskRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var task = await GetTaskForUserAsync(userId, taskId, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
throw new BadRequestException("Task title is required");
|
||||
}
|
||||
|
||||
if (request.AssigneeId.HasValue && !await IsMemberAsync(request.AssigneeId.Value, task.ProjectId, 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 GetDtoAsync(task.Id, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var task = await GetTaskForUserAsync(userId, taskId, ct);
|
||||
uow.Tasks.Remove(task);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TaskDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default)
|
||||
{
|
||||
var projectIds = await uow.Projects.GetProjectIdsForUserAsync(userId, ct);
|
||||
var tasks = await uow.Tasks.SearchWithAssigneeAsync(projectIds, term, 20, ct);
|
||||
return mapper.Map<List<TaskDto>>(tasks);
|
||||
}
|
||||
|
||||
private async Task<TaskDto> GetDtoAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await uow.Tasks.GetWithAssigneeAsync(id, ct)
|
||||
?? throw new NotFoundException("Task not found");
|
||||
return mapper.Map<TaskDto>(task);
|
||||
}
|
||||
|
||||
private async Task<TaskItem> GetTaskForUserAsync(Guid userId, Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var task = await uow.Tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new NotFoundException("Task not found");
|
||||
|
||||
if (!await IsMemberAsync(userId, task.ProjectId, ct))
|
||||
{
|
||||
throw new NotFoundException("Task not found");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private async Task EnsureMemberAccessAsync(Guid userId, Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await IsMemberAsync(userId, projectId, ct))
|
||||
{
|
||||
throw new NotFoundException("Project not found");
|
||||
}
|
||||
}
|
||||
|
||||
private Task<bool> IsMemberAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
|
||||
uow.Projects.IsMemberAsync(projectId, userId, ct);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\mws.domain\mws.domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user