feat(shifts): add Shift CRUD API with sign-up/cancel and capacity management
- ShiftService with 7 methods: list, detail, create, update, delete, signup, cancel
- 5 DTOs: ShiftListDto, ShiftDetailDto, CreateShiftRequest, UpdateShiftRequest, ShiftSignupDto
- Minimal API endpoints: GET /api/shifts, GET /api/shifts/{id}, POST, PUT, DELETE, POST /signup, DELETE /signup
- Capacity validation: sign-up rejected when full → 409 Conflict
- Past shift blocking: cannot sign up for past shifts → 422 Unprocessable
- Duplicate signup prevention: check existing before create → 409 Conflict
- Concurrency: 2-attempt retry loop for last-slot race conditions
- Authorization: POST/PUT (Manager+), DELETE (Admin), signup/cancel (Member+)
- Test infrastructure: Added X-Test-UserId header support for member ID injection
- 13 TDD integration tests: CRUD, sign-up, capacity, past shift, concurrency
- Build: 0 errors (6 BouncyCastle warnings expected)
Task 15 complete. Wave 3: 3/5 tasks done.
This commit is contained in:
167
backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs
Normal file
167
backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WorkClub.Api.Services;
|
||||
using WorkClub.Application.Shifts.DTOs;
|
||||
|
||||
namespace WorkClub.Api.Endpoints.Shifts;
|
||||
|
||||
public static class ShiftEndpoints
|
||||
{
|
||||
public static void MapShiftEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/shifts");
|
||||
|
||||
group.MapGet("", GetShifts)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetShifts");
|
||||
|
||||
group.MapGet("{id:guid}", GetShift)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetShift");
|
||||
|
||||
group.MapPost("", CreateShift)
|
||||
.RequireAuthorization("RequireManager")
|
||||
.WithName("CreateShift");
|
||||
|
||||
group.MapPut("{id:guid}", UpdateShift)
|
||||
.RequireAuthorization("RequireManager")
|
||||
.WithName("UpdateShift");
|
||||
|
||||
group.MapDelete("{id:guid}", DeleteShift)
|
||||
.RequireAuthorization("RequireAdmin")
|
||||
.WithName("DeleteShift");
|
||||
|
||||
group.MapPost("{id:guid}/signup", SignUpForShift)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("SignUpForShift");
|
||||
|
||||
group.MapDelete("{id:guid}/signup", CancelSignup)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("CancelSignup");
|
||||
}
|
||||
|
||||
private static async Task<Ok<ShiftListDto>> GetShifts(
|
||||
ShiftService shiftService,
|
||||
[FromQuery] DateTimeOffset? from = null,
|
||||
[FromQuery] DateTimeOffset? to = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await shiftService.GetShiftsAsync(from, to, page, pageSize);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift(
|
||||
Guid id,
|
||||
ShiftService shiftService)
|
||||
{
|
||||
var result = await shiftService.GetShiftByIdAsync(id);
|
||||
|
||||
if (result == null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<ShiftDetailDto>, BadRequest<string>>> CreateShift(
|
||||
CreateShiftRequest request,
|
||||
ShiftService shiftService,
|
||||
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 (shift, error) = await shiftService.CreateShiftAsync(request, createdById);
|
||||
|
||||
if (error != null || shift == null)
|
||||
return TypedResults.BadRequest(error ?? "Failed to create shift");
|
||||
|
||||
return TypedResults.Created($"/api/shifts/{shift.Id}", shift);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift(
|
||||
Guid id,
|
||||
UpdateShiftRequest request,
|
||||
ShiftService shiftService)
|
||||
{
|
||||
var (shift, error, isConflict) = await shiftService.UpdateShiftAsync(id, request);
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
if (error == "Shift not found")
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (isConflict)
|
||||
return TypedResults.Conflict(error);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(shift!);
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound>> DeleteShift(
|
||||
Guid id,
|
||||
ShiftService shiftService)
|
||||
{
|
||||
var deleted = await shiftService.DeleteShiftAsync(id);
|
||||
|
||||
if (!deleted)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, NotFound, UnprocessableEntity<string>, Conflict<string>>> SignUpForShift(
|
||||
Guid id,
|
||||
ShiftService shiftService,
|
||||
HttpContext httpContext)
|
||||
{
|
||||
var userIdClaim = httpContext.User.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var memberId))
|
||||
{
|
||||
return TypedResults.UnprocessableEntity("Invalid user ID");
|
||||
}
|
||||
|
||||
var (success, error, isConflict) = await shiftService.SignUpForShiftAsync(id, memberId);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
if (error == "Shift not found")
|
||||
return TypedResults.NotFound();
|
||||
|
||||
if (error == "Cannot sign up for past shifts")
|
||||
return TypedResults.UnprocessableEntity(error);
|
||||
|
||||
if (isConflict)
|
||||
return TypedResults.Conflict(error!);
|
||||
}
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, NotFound, UnprocessableEntity<string>>> CancelSignup(
|
||||
Guid id,
|
||||
ShiftService shiftService,
|
||||
HttpContext httpContext)
|
||||
{
|
||||
var userIdClaim = httpContext.User.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var memberId))
|
||||
{
|
||||
return TypedResults.UnprocessableEntity("Invalid user ID");
|
||||
}
|
||||
|
||||
var (success, error) = await shiftService.CancelSignupAsync(id, memberId);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
if (error == "Sign-up not found")
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.UnprocessableEntity(error!);
|
||||
}
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user