diff --git a/mws.api/Controllers/TasksController.cs b/mws.api/Controllers/TasksController.cs index 3be77b3..6f448f4 100644 --- a/mws.api/Controllers/TasksController.cs +++ b/mws.api/Controllers/TasksController.cs @@ -1,3 +1,4 @@ +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Mws.Application.Common; @@ -10,12 +11,12 @@ namespace Mws.Api.Controllers; [ApiController] [Route("api")] [Authorize] -public class TasksController(ITaskService taskService) : ControllerBase +public class TasksController(ISender sender) : ControllerBase { [HttpGet("tasks/search")] public async Task>> Search([FromQuery] string? q, CancellationToken ct) { - return Ok(await taskService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct)); + return Ok(await sender.Send(new SearchTasksQuery(User.GetUserId(), q ?? string.Empty), ct)); } [HttpGet("projects/{projectId:guid}/tasks")] @@ -28,32 +29,32 @@ public class TasksController(ITaskService taskService) : ControllerBase { var statusValue = ParseOptional(status); var priorityValue = ParseOptional(priority); - return Ok(await taskService.GetTasksAsync(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId, ct)); + return Ok(await sender.Send(new GetTasksQuery(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId), ct)); } [HttpPost("projects/{projectId:guid}/tasks")] public async Task> Create(Guid projectId, [FromBody] CreateTaskRequest request, CancellationToken ct) { - var result = await taskService.CreateAsync(User.GetUserId(), projectId, request, ct); + var result = await sender.Send(new CreateTaskCommand(User.GetUserId(), projectId, request), ct); return CreatedAtAction(nameof(Get), new { id = result.Id }, result); } [HttpGet("tasks/{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { - return Ok(await taskService.GetAsync(User.GetUserId(), id, ct)); + return Ok(await sender.Send(new GetTaskQuery(User.GetUserId(), id), ct)); } [HttpPut("tasks/{id:guid}")] public async Task> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) { - return Ok(await taskService.UpdateAsync(User.GetUserId(), id, request, ct)); + return Ok(await sender.Send(new UpdateTaskCommand(User.GetUserId(), id, request), ct)); } [HttpDelete("tasks/{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) { - await taskService.DeleteAsync(User.GetUserId(), id, ct); + await sender.Send(new DeleteTaskCommand(User.GetUserId(), id), ct); return NoContent(); } @@ -65,4 +66,4 @@ public class TasksController(ITaskService taskService) : ControllerBase } return Enum.TryParse(value, ignoreCase: true, out var result) ? result : throw new BadRequestException($"Invalid {typeof(TEnum).Name}: {value}"); } -} \ No newline at end of file +} diff --git a/mws.application/Tasks/Commands/CreateTask.cs b/mws.application/Tasks/Commands/CreateTask.cs new file mode 100644 index 0000000..006e15d --- /dev/null +++ b/mws.application/Tasks/Commands/CreateTask.cs @@ -0,0 +1,49 @@ +using AutoMapper; +using MediatR; +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 record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest; + +public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(CreateTaskCommand command, CancellationToken ct) + { + await TaskAccess.EnsureMemberAccessAsync(uow, command.UserId, command.ProjectId, ct); + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Task title is required"); + } + + if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(command.ProjectId, request.AssigneeId.Value, ct)) + { + throw new BadRequestException("Assignee must be a member of the project"); + } + + var now = DateTime.UtcNow; + var task = new TaskItem + { + Id = Guid.NewGuid(), + ProjectId = command.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 = command.UserId, + CreatedAt = now, + UpdatedAt = now, + }; + + uow.Tasks.Add(task); + await uow.SaveChangesAsync(ct); + return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct); + } +} diff --git a/mws.application/Tasks/Commands/DeleteTask.cs b/mws.application/Tasks/Commands/DeleteTask.cs new file mode 100644 index 0000000..e98379c --- /dev/null +++ b/mws.application/Tasks/Commands/DeleteTask.cs @@ -0,0 +1,16 @@ +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest; + +public class DeleteTaskHandler(IUnitOfWork uow) : IRequestHandler +{ + public async Task Handle(DeleteTaskCommand command, CancellationToken ct) + { + var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct); + uow.Tasks.Remove(task); + await uow.SaveChangesAsync(ct); + } +} diff --git a/mws.application/Tasks/Commands/UpdateTask.cs b/mws.application/Tasks/Commands/UpdateTask.cs new file mode 100644 index 0000000..b88f806 --- /dev/null +++ b/mws.application/Tasks/Commands/UpdateTask.cs @@ -0,0 +1,37 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest; + +public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(UpdateTaskCommand command, CancellationToken ct) + { + var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct); + + var request = command.Request; + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new BadRequestException("Task title is required"); + } + + if (request.AssigneeId.HasValue && !await uow.Projects.IsMemberAsync(task.ProjectId, request.AssigneeId.Value, 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 TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct); + } +} diff --git a/mws.application/Tasks/ITaskService.cs b/mws.application/Tasks/ITaskService.cs deleted file mode 100644 index 7e65757..0000000 --- a/mws.application/Tasks/ITaskService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Mws.Application.Common; -using TaskPriority = Mws.Domain.Tasks.TaskPriority; -using TaskStatus = Mws.Domain.Tasks.TaskStatus; - -namespace Mws.Application.Tasks; - -public interface ITaskService -{ - Task> GetTasksAsync(Guid userId, Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default); - Task GetAsync(Guid userId, Guid taskId, CancellationToken ct = default); - Task CreateAsync(Guid userId, Guid projectId, CreateTaskRequest request, CancellationToken ct = default); - Task UpdateAsync(Guid userId, Guid taskId, UpdateTaskRequest request, CancellationToken ct = default); - Task DeleteAsync(Guid userId, Guid taskId, CancellationToken ct = default); - Task> SearchAsync(Guid userId, string term, CancellationToken ct = default); -} \ No newline at end of file diff --git a/mws.application/Tasks/Queries/GetTask.cs b/mws.application/Tasks/Queries/GetTask.cs new file mode 100644 index 0000000..f6e909e --- /dev/null +++ b/mws.application/Tasks/Queries/GetTask.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record GetTaskQuery(Guid UserId, Guid TaskId) : IRequest; + +public class GetTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +{ + public async Task Handle(GetTaskQuery query, CancellationToken ct) + { + var task = await TaskAccess.GetTaskForUserAsync(uow, query.UserId, query.TaskId, ct); + return await TaskAccess.GetDtoAsync(uow, mapper, task.Id, ct); + } +} diff --git a/mws.application/Tasks/Queries/GetTasks.cs b/mws.application/Tasks/Queries/GetTasks.cs new file mode 100644 index 0000000..517bcbf --- /dev/null +++ b/mws.application/Tasks/Queries/GetTasks.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using TaskPriority = Mws.Domain.Tasks.TaskPriority; +using TaskStatus = Mws.Domain.Tasks.TaskStatus; + +namespace Mws.Application.Tasks; + +public record GetTasksQuery(Guid UserId, Guid ProjectId, TaskStatus? Status, TaskPriority? Priority, Guid? AssigneeId) + : IRequest>; + +public class GetTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(GetTasksQuery query, CancellationToken ct) + { + await TaskAccess.EnsureMemberAccessAsync(uow, query.UserId, query.ProjectId, ct); + + var tasks = await uow.Tasks.GetForProjectAsync(query.ProjectId, query.Status, query.Priority, query.AssigneeId, ct); + return mapper.Map>(tasks); + } +} diff --git a/mws.application/Tasks/Queries/SearchTasks.cs b/mws.application/Tasks/Queries/SearchTasks.cs new file mode 100644 index 0000000..5a887bc --- /dev/null +++ b/mws.application/Tasks/Queries/SearchTasks.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; + +namespace Mws.Application.Tasks; + +public record SearchTasksQuery(Guid UserId, string Term) : IRequest>; + +public class SearchTasksHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler> +{ + public async Task> Handle(SearchTasksQuery query, CancellationToken ct) + { + var projectIds = await uow.Projects.GetProjectIdsForUserAsync(query.UserId, ct); + var tasks = await uow.Tasks.SearchWithAssigneeAsync(projectIds, query.Term, 20, ct); + return mapper.Map>(tasks); + } +} diff --git a/mws.application/Tasks/TaskAccess.cs b/mws.application/Tasks/TaskAccess.cs new file mode 100644 index 0000000..88d0e53 --- /dev/null +++ b/mws.application/Tasks/TaskAccess.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using Mws.Application.Common; +using Mws.Domain.Tasks; + +namespace Mws.Application.Tasks; + +internal static class TaskAccess +{ + public static async Task GetDtoAsync(IUnitOfWork uow, IMapper mapper, Guid id, CancellationToken ct) + { + var task = await uow.Tasks.GetWithAssigneeAsync(id, ct) + ?? throw new NotFoundException("Task not found"); + return mapper.Map(task); + } + + public static async Task GetTaskForUserAsync(IUnitOfWork uow, Guid userId, Guid taskId, CancellationToken ct) + { + var task = await uow.Tasks.GetByIdAsync(taskId, ct) + ?? throw new NotFoundException("Task not found"); + + if (!await uow.Projects.IsMemberAsync(task.ProjectId, userId, ct)) + { + throw new NotFoundException("Task not found"); + } + return task; + } + + public static async Task EnsureMemberAccessAsync(IUnitOfWork uow, Guid userId, Guid projectId, CancellationToken ct) + { + if (!await uow.Projects.IsMemberAsync(projectId, userId, ct)) + { + throw new NotFoundException("Project not found"); + } + } +} diff --git a/mws.application/Tasks/TaskService.cs b/mws.application/Tasks/TaskService.cs deleted file mode 100644 index 8f67346..0000000 --- a/mws.application/Tasks/TaskService.cs +++ /dev/null @@ -1,129 +0,0 @@ -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> 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>(tasks); - } - - public async Task GetAsync(Guid userId, Guid taskId, CancellationToken ct = default) - { - var task = await GetTaskForUserAsync(userId, taskId, ct); - return await GetDtoAsync(task.Id, ct); - } - - public async Task 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 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> 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>(tasks); - } - - private async Task GetDtoAsync(Guid id, CancellationToken ct) - { - var task = await uow.Tasks.GetWithAssigneeAsync(id, ct) - ?? throw new NotFoundException("Task not found"); - return mapper.Map(task); - } - - private async Task 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 IsMemberAsync(Guid userId, Guid projectId, CancellationToken ct = default) => - uow.Projects.IsMemberAsync(projectId, userId, ct); -} diff --git a/mws.infrastructure/DependencyInjection.cs b/mws.infrastructure/DependencyInjection.cs index 3f15e3e..d297a50 100644 --- a/mws.infrastructure/DependencyInjection.cs +++ b/mws.infrastructure/DependencyInjection.cs @@ -5,7 +5,6 @@ using Mws.Application.Auth; using Mws.Application.Common; using Mws.Application.Permissions; using Mws.Application.Projects; -using Mws.Application.Tasks; using Mws.Infrastructure.Authentication; using Mws.Infrastructure.Persistence; @@ -38,7 +37,6 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); - services.AddScoped(); return services; }