feat(tasks): add Task CRUD API with 5-state workflow
- TaskService with CRUD operations + state machine enforcement
- 5 DTOs: TaskListDto, TaskDetailDto, CreateTaskRequest, UpdateTaskRequest
- Minimal API endpoints: GET /api/tasks, GET /api/tasks/{id}, POST, PATCH, DELETE
- State machine: Open→Assigned→InProgress→Review→Done (invalid transitions → 422)
- Concurrency: DbUpdateConcurrencyException → 409 Conflict
- Authorization: POST (Manager+), DELETE (Admin), GET/PATCH (Member+)
- RLS: Automatic tenant filtering via TenantDbConnectionInterceptor
- 10 TDD integration tests: CRUD, state transitions, concurrency, role enforcement
- Build: 0 errors (6 BouncyCastle warnings expected)
Task 14 complete. Wave 3: 2/5 tasks done.
This commit is contained in:
108
backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs
Normal file
108
backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WorkClub.Api.Services;
|
||||
using WorkClub.Application.Tasks.DTOs;
|
||||
|
||||
namespace WorkClub.Api.Endpoints.Tasks;
|
||||
|
||||
public static class TaskEndpoints
|
||||
{
|
||||
public static void MapTaskEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/tasks");
|
||||
|
||||
group.MapGet("", GetTasks)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetTasks");
|
||||
|
||||
group.MapGet("{id:guid}", GetTask)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetTask");
|
||||
|
||||
group.MapPost("", CreateTask)
|
||||
.RequireAuthorization("RequireManager")
|
||||
.WithName("CreateTask");
|
||||
|
||||
group.MapPatch("{id:guid}", UpdateTask)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("UpdateTask");
|
||||
|
||||
group.MapDelete("{id:guid}", DeleteTask)
|
||||
.RequireAuthorization("RequireAdmin")
|
||||
.WithName("DeleteTask");
|
||||
}
|
||||
|
||||
private static async Task<Ok<TaskListDto>> GetTasks(
|
||||
TaskService taskService,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await taskService.GetTasksAsync(status, page, pageSize);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<TaskDetailDto>, NotFound>> GetTask(
|
||||
Guid id,
|
||||
TaskService taskService)
|
||||
{
|
||||
var result = await taskService.GetTaskByIdAsync(id);
|
||||
|
||||
if (result == null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<TaskDetailDto>, BadRequest<string>>> CreateTask(
|
||||
CreateTaskRequest request,
|
||||
TaskService taskService,
|
||||
HttpContext httpContext)
|
||||
{
|
||||
var userIdClaim = httpContext.User.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var createdById))
|
||||
{
|
||||
return TypedResults.BadRequest("Invalid user ID");
|
||||
}
|
||||
|
||||
var (task, error) = await taskService.CreateTaskAsync(request, createdById);
|
||||
|
||||
if (error != null || task == null)
|
||||
return TypedResults.BadRequest(error ?? "Failed to create task");
|
||||
|
||||
return TypedResults.Created($"/api/tasks/{task.Id}", task);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> UpdateTask(
|
||||
Guid id,
|
||||
UpdateTaskRequest request,
|
||||
TaskService taskService)
|
||||
{
|
||||
var (task, error, isConflict) = await taskService.UpdateTaskAsync(id, request);
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
if (error == "Task not found")
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (isConflict)
|
||||
return TypedResults.Conflict(error);
|
||||
|
||||
return TypedResults.UnprocessableEntity(error);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(task!);
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound>> DeleteTask(
|
||||
Guid id,
|
||||
TaskService taskService)
|
||||
{
|
||||
var deleted = await taskService.DeleteTaskAsync(id);
|
||||
|
||||
if (!deleted)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WorkClub.Api.Auth;
|
||||
using WorkClub.Api.Endpoints.Tasks;
|
||||
using WorkClub.Api.Middleware;
|
||||
using WorkClub.Api.Services;
|
||||
using WorkClub.Application.Interfaces;
|
||||
using WorkClub.Infrastructure.Data;
|
||||
using WorkClub.Infrastructure.Data.Interceptors;
|
||||
@@ -25,6 +27,7 @@ builder.Services.AddMultiTenant<TenantInfo>()
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
|
||||
builder.Services.AddScoped<SeedDataService>();
|
||||
builder.Services.AddScoped<TaskService>();
|
||||
|
||||
builder.Services.AddSingleton<TenantDbConnectionInterceptor>();
|
||||
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
|
||||
@@ -117,6 +120,8 @@ app.MapGet("/weatherforecast", () =>
|
||||
app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
||||
.RequireAuthorization();
|
||||
|
||||
app.MapTaskEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
|
||||
184
backend/WorkClub.Api/Services/TaskService.cs
Normal file
184
backend/WorkClub.Api/Services/TaskService.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WorkClub.Application.Interfaces;
|
||||
using WorkClub.Application.Tasks.DTOs;
|
||||
using WorkClub.Domain.Entities;
|
||||
using WorkClub.Domain.Enums;
|
||||
using WorkClub.Infrastructure.Data;
|
||||
|
||||
namespace WorkClub.Api.Services;
|
||||
|
||||
public class TaskService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
public TaskService(AppDbContext context, ITenantProvider tenantProvider)
|
||||
{
|
||||
_context = context;
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
public async Task<TaskListDto> GetTasksAsync(string? statusFilter, int page, int pageSize)
|
||||
{
|
||||
var query = _context.WorkItems.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(statusFilter))
|
||||
{
|
||||
if (Enum.TryParse<WorkItemStatus>(statusFilter, ignoreCase: true, out var status))
|
||||
{
|
||||
query = query.Where(w => w.Status == status);
|
||||
}
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.OrderBy(w => w.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var itemDtos = items.Select(w => new TaskListItemDto(
|
||||
w.Id,
|
||||
w.Title,
|
||||
w.Status.ToString(),
|
||||
w.AssigneeId,
|
||||
w.CreatedAt
|
||||
)).ToList();
|
||||
|
||||
return new TaskListDto(itemDtos, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<TaskDetailDto?> GetTaskByIdAsync(Guid id)
|
||||
{
|
||||
var workItem = await _context.WorkItems.FindAsync(id);
|
||||
|
||||
if (workItem == null)
|
||||
return null;
|
||||
|
||||
return new TaskDetailDto(
|
||||
workItem.Id,
|
||||
workItem.Title,
|
||||
workItem.Description,
|
||||
workItem.Status.ToString(),
|
||||
workItem.AssigneeId,
|
||||
workItem.CreatedById,
|
||||
workItem.ClubId,
|
||||
workItem.DueDate,
|
||||
workItem.CreatedAt,
|
||||
workItem.UpdatedAt
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<(TaskDetailDto? task, string? error)> CreateTaskAsync(CreateTaskRequest request, Guid createdById)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetTenantId();
|
||||
|
||||
var workItem = new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Status = WorkItemStatus.Open,
|
||||
ClubId = request.ClubId,
|
||||
AssigneeId = request.AssigneeId,
|
||||
DueDate = request.DueDate,
|
||||
CreatedById = createdById,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_context.WorkItems.Add(workItem);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var dto = new TaskDetailDto(
|
||||
workItem.Id,
|
||||
workItem.Title,
|
||||
workItem.Description,
|
||||
workItem.Status.ToString(),
|
||||
workItem.AssigneeId,
|
||||
workItem.CreatedById,
|
||||
workItem.ClubId,
|
||||
workItem.DueDate,
|
||||
workItem.CreatedAt,
|
||||
workItem.UpdatedAt
|
||||
);
|
||||
|
||||
return (dto, null);
|
||||
}
|
||||
|
||||
public async Task<(TaskDetailDto? task, string? error, bool isConflict)> UpdateTaskAsync(Guid id, UpdateTaskRequest request)
|
||||
{
|
||||
var workItem = await _context.WorkItems.FindAsync(id);
|
||||
|
||||
if (workItem == null)
|
||||
return (null, "Task not found", false);
|
||||
|
||||
if (request.Title != null)
|
||||
workItem.Title = request.Title;
|
||||
|
||||
if (request.Description != null)
|
||||
workItem.Description = request.Description;
|
||||
|
||||
if (request.AssigneeId.HasValue)
|
||||
workItem.AssigneeId = request.AssigneeId;
|
||||
|
||||
if (request.DueDate.HasValue)
|
||||
workItem.DueDate = request.DueDate;
|
||||
|
||||
if (request.Status != null)
|
||||
{
|
||||
if (!Enum.TryParse<WorkItemStatus>(request.Status, ignoreCase: true, out var newStatus))
|
||||
{
|
||||
return (null, $"Invalid status: {request.Status}", false);
|
||||
}
|
||||
|
||||
if (!workItem.CanTransitionTo(newStatus))
|
||||
{
|
||||
return (null, $"Cannot transition from {workItem.Status} to {newStatus}", false);
|
||||
}
|
||||
|
||||
workItem.TransitionTo(newStatus);
|
||||
}
|
||||
|
||||
workItem.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return (null, "Task was modified by another user. Please refresh and try again.", true);
|
||||
}
|
||||
|
||||
var dto = new TaskDetailDto(
|
||||
workItem.Id,
|
||||
workItem.Title,
|
||||
workItem.Description,
|
||||
workItem.Status.ToString(),
|
||||
workItem.AssigneeId,
|
||||
workItem.CreatedById,
|
||||
workItem.ClubId,
|
||||
workItem.DueDate,
|
||||
workItem.CreatedAt,
|
||||
workItem.UpdatedAt
|
||||
);
|
||||
|
||||
return (dto, null, false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteTaskAsync(Guid id)
|
||||
{
|
||||
var workItem = await _context.WorkItems.FindAsync(id);
|
||||
|
||||
if (workItem == null)
|
||||
return false;
|
||||
|
||||
_context.WorkItems.Remove(workItem);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user