diff --git a/mws.api/Controllers/ProjectsController.cs b/mws.api/Controllers/ProjectsController.cs index 25f91b9..0345d92 100644 --- a/mws.api/Controllers/ProjectsController.cs +++ b/mws.api/Controllers/ProjectsController.cs @@ -1,3 +1,4 @@ +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Mws.Application.Projects; @@ -7,49 +8,49 @@ namespace Mws.Api.Controllers; [ApiController] [Route("api/projects")] [Authorize] -public class ProjectsController(IProjectService projectService) : ControllerBase +public class ProjectsController(ISender sender) : ControllerBase { [HttpGet] public async Task>> GetAll(CancellationToken ct) { - return Ok(await projectService.GetProjectsAsync(User.GetUserId(), ct)); + return Ok(await sender.Send(new GetProjectsQuery(User.GetUserId()), ct)); } [HttpGet("search")] public async Task>> Search([FromQuery] string? q, CancellationToken ct) { - return Ok(await projectService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct)); + return Ok(await sender.Send(new SearchProjectsQuery(User.GetUserId(), q ?? string.Empty), ct)); } [HttpGet("{projectId:guid}")] public async Task> Get(Guid projectId, CancellationToken ct) { - return Ok(await projectService.GetProjectAsync(User.GetUserId(), projectId, ct)); + return Ok(await sender.Send(new GetProjectQuery(User.GetUserId(), projectId), ct)); } [HttpGet("{projectId:guid}/overview")] public async Task> Overview(Guid projectId, CancellationToken ct) { - return Ok(await projectService.GetOverviewAsync(User.GetUserId(), projectId, ct)); + return Ok(await sender.Send(new GetProjectOverviewQuery(User.GetUserId(), projectId), ct)); } [HttpPost] public async Task> Create([FromBody] CreateProjectRequest request, CancellationToken ct) { - var result = await projectService.CreateProjectAsync(User.GetUserId(), request, ct); + var result = await sender.Send(new CreateProjectCommand(User.GetUserId(), request), ct); return CreatedAtAction(nameof(Get), new { projectId = result.Id }, result); } [HttpPut("{projectId:guid}")] public async Task> Update(Guid projectId, [FromBody] UpdateProjectRequest request, CancellationToken ct) { - return Ok(await projectService.UpdateProjectAsync(User.GetUserId(), projectId, request, ct)); + return Ok(await sender.Send(new UpdateProjectCommand(User.GetUserId(), projectId, request), ct)); } [HttpDelete("{projectId:guid}")] public async Task Delete(Guid projectId, CancellationToken ct) { - await projectService.ArchiveProjectAsync(User.GetUserId(), projectId, ct); + await sender.Send(new ArchiveProjectCommand(User.GetUserId(), projectId), ct); return NoContent(); } -} \ No newline at end of file +} diff --git a/mws.application/Projects/Commands/ArchiveProject.cs b/mws.application/Projects/Commands/ArchiveProject.cs new file mode 100644 index 0000000..ce96c73 --- /dev/null +++ b/mws.application/Projects/Commands/ArchiveProject.cs @@ -0,0 +1,24 @@ +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest; + +public class ArchiveProjectHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(ArchiveProjectCommand command, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); + + if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, 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); + } +} diff --git a/mws.application/Projects/Commands/CreateProject.cs b/mws.application/Projects/Commands/CreateProject.cs new file mode 100644 index 0000000..c4df59a --- /dev/null +++ b/mws.application/Projects/Commands/CreateProject.cs @@ -0,0 +1,42 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest; + +public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(CreateProjectCommand command, CancellationToken ct) + { + var request = command.Request; + 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 = command.UserId, + Role = MemberRole.Owner, + }); + + uow.Projects.Add(project); + await uow.SaveChangesAsync(ct); + return mapper.Map(project); + } +} diff --git a/mws.application/Projects/Commands/UpdateProject.cs b/mws.application/Projects/Commands/UpdateProject.cs new file mode 100644 index 0000000..ca59940 --- /dev/null +++ b/mws.application/Projects/Commands/UpdateProject.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest; + +public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(UpdateProjectCommand command, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); + + if (MemberRole.Owner != await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct)) + { + throw new ForbiddenException("Only the project owner can update the project"); + } + + var request = command.Request; + 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(project); + } +} diff --git a/mws.application/Projects/IProjectService.cs b/mws.application/Projects/IProjectService.cs index ed82c92..b0290e4 100644 --- a/mws.application/Projects/IProjectService.cs +++ b/mws.application/Projects/IProjectService.cs @@ -1,22 +1,9 @@ -using Mws.Application.Common; - namespace Mws.Application.Projects; -public interface IProjectService -{ - Task> GetProjectsAsync(Guid userId, CancellationToken ct = default); - Task GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default); - Task CreateProjectAsync(Guid userId, CreateProjectRequest request, CancellationToken ct = default); - Task UpdateProjectAsync(Guid userId, Guid projectId, UpdateProjectRequest request, CancellationToken ct = default); - Task ArchiveProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default); - Task GetOverviewAsync(Guid userId, Guid projectId, CancellationToken ct = default); - Task> SearchAsync(Guid userId, string term, CancellationToken ct = default); -} - public interface IProjectMemberService { Task> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default); Task AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default); Task UpdateMemberDocumentPermissionsAsync(Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default); Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default); -} \ No newline at end of file +} diff --git a/mws.application/Projects/ProjectAccess.cs b/mws.application/Projects/ProjectAccess.cs new file mode 100644 index 0000000..23d80ac --- /dev/null +++ b/mws.application/Projects/ProjectAccess.cs @@ -0,0 +1,13 @@ +using Mws.Application.Common; +using Mws.Domain.Projects; + +namespace Mws.Application.Projects; + +internal static class ProjectAccess +{ + public static async Task GetForUserOrThrowAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) + { + return await uow.Projects.GetForUserAsync(userId, projectId, ct) + ?? throw new NotFoundException("Project not found"); + } +} diff --git a/mws.application/Projects/ProjectService.cs b/mws.application/Projects/ProjectService.cs deleted file mode 100644 index 03abc50..0000000 --- a/mws.application/Projects/ProjectService.cs +++ /dev/null @@ -1,127 +0,0 @@ -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> GetProjectsAsync(Guid userId, CancellationToken ct = default) - { - var projects = await uow.Projects.GetForUserAsync(userId, ct); - return mapper.Map>(projects); - } - - public async Task GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default) - { - var project = await GetProjectForUserAsync(userId, projectId, ct); - return mapper.Map(project); - } - - public async Task 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(project); - } - - public async Task 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(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 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(project), - MemberCount = memberCount, - DocumentCount = documentCount, - TaskCountsByStatus = taskCountsByStatus, - RecentTasks = mapper.Map>(recentTasks), - RecentDocuments = mapper.Map>(recentDocuments), - }; - } - - public async Task> SearchAsync(Guid userId, string term, CancellationToken ct = default) - { - var projects = await uow.Projects.SearchForUserAsync(userId, term, ct); - return mapper.Map>(projects); - } - - protected async Task GetProjectForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default) - { - return await uow.Projects.GetForUserAsync(userId, projectId, ct) - ?? throw new NotFoundException("Project not found"); - } - - protected Task GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) => - uow.Projects.GetMemberRoleAsync(projectId, userId, ct); -} diff --git a/mws.application/Projects/Queries/GetProject.cs b/mws.application/Projects/Queries/GetProject.cs new file mode 100644 index 0000000..abb7d11 --- /dev/null +++ b/mws.application/Projects/Queries/GetProject.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Projects; + +public record GetProjectQuery(Guid UserId, Guid ProjectId) : IRequest; + +public class GetProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetProjectQuery query, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct); + return mapper.Map(project); + } +} diff --git a/mws.application/Projects/Queries/GetProjectOverview.cs b/mws.application/Projects/Queries/GetProjectOverview.cs new file mode 100644 index 0000000..c9b7ed5 --- /dev/null +++ b/mws.application/Projects/Queries/GetProjectOverview.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Domain.Documents; + +namespace Mws.Application.Projects; + +public record GetProjectOverviewQuery(Guid UserId, Guid ProjectId) : IRequest; + +public class GetProjectOverviewHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetProjectOverviewQuery query, CancellationToken ct) + { + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, query.UserId, query.ProjectId, ct); + + var memberCount = await uow.Projects.CountMembersAsync(query.ProjectId, ct); + var documentCount = await uow.Documents.CountByTypeForProjectAsync(query.ProjectId, DocumentType.Document, ct); + + var taskCounts = await uow.Tasks.CountByStatusForProjectAsync(query.ProjectId, ct); + var taskCountsByStatus = taskCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value); + + var recentTasks = await uow.Tasks.GetRecentForProjectAsync(query.ProjectId, 5, ct); + var recentDocuments = await uow.Documents.GetRecentForProjectAsync(query.ProjectId, DocumentType.Document, 5, ct); + + return new ProjectOverviewDto + { + Project = mapper.Map(project), + MemberCount = memberCount, + DocumentCount = documentCount, + TaskCountsByStatus = taskCountsByStatus, + RecentTasks = mapper.Map>(recentTasks), + RecentDocuments = mapper.Map>(recentDocuments), + }; + } +} diff --git a/mws.application/Projects/Queries/GetProjects.cs b/mws.application/Projects/Queries/GetProjects.cs new file mode 100644 index 0000000..ce71d6f --- /dev/null +++ b/mws.application/Projects/Queries/GetProjects.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Projects; + +public record GetProjectsQuery(Guid UserId) : IRequest>; + +public class GetProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetProjectsQuery query, CancellationToken ct) + { + var projects = await uow.Projects.GetForUserAsync(query.UserId, ct); + return mapper.Map>(projects); + } +} diff --git a/mws.application/Projects/Queries/SearchProjects.cs b/mws.application/Projects/Queries/SearchProjects.cs new file mode 100644 index 0000000..d1c0ec5 --- /dev/null +++ b/mws.application/Projects/Queries/SearchProjects.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Projects; + +public record SearchProjectsQuery(Guid UserId, string Term) : IRequest>; + +public class SearchProjectsHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(SearchProjectsQuery query, CancellationToken ct) + { + var projects = await uow.Projects.SearchForUserAsync(query.UserId, query.Term, ct); + return mapper.Map>(projects); + } +} diff --git a/mws.infrastructure/DependencyInjection.cs b/mws.infrastructure/DependencyInjection.cs index bfa56e9..7246c18 100644 --- a/mws.infrastructure/DependencyInjection.cs +++ b/mws.infrastructure/DependencyInjection.cs @@ -39,7 +39,6 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();