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();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WorkClub.Api.Auth;
|
||||
using WorkClub.Api.Endpoints.Shifts;
|
||||
using WorkClub.Api.Endpoints.Tasks;
|
||||
using WorkClub.Api.Middleware;
|
||||
using WorkClub.Api.Services;
|
||||
@@ -28,6 +29,7 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
|
||||
builder.Services.AddScoped<SeedDataService>();
|
||||
builder.Services.AddScoped<TaskService>();
|
||||
builder.Services.AddScoped<ShiftService>();
|
||||
|
||||
builder.Services.AddSingleton<TenantDbConnectionInterceptor>();
|
||||
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
|
||||
@@ -121,6 +123,7 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
||||
.RequireAuthorization();
|
||||
|
||||
app.MapTaskEndpoints();
|
||||
app.MapShiftEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
283
backend/WorkClub.Api/Services/ShiftService.cs
Normal file
283
backend/WorkClub.Api/Services/ShiftService.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WorkClub.Application.Interfaces;
|
||||
using WorkClub.Application.Shifts.DTOs;
|
||||
using WorkClub.Domain.Entities;
|
||||
using WorkClub.Infrastructure.Data;
|
||||
|
||||
namespace WorkClub.Api.Services;
|
||||
|
||||
public class ShiftService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
public ShiftService(AppDbContext context, ITenantProvider tenantProvider)
|
||||
{
|
||||
_context = context;
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
public async Task<ShiftListDto> GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize)
|
||||
{
|
||||
var query = _context.Shifts.AsQueryable();
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(s => s.StartTime >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(s => s.StartTime <= to.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
var shifts = await query
|
||||
.OrderBy(s => s.StartTime)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var shiftIds = shifts.Select(s => s.Id).ToList();
|
||||
var signupCounts = await _context.ShiftSignups
|
||||
.Where(ss => shiftIds.Contains(ss.ShiftId))
|
||||
.GroupBy(ss => ss.ShiftId)
|
||||
.Select(g => new { ShiftId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ShiftId, x => x.Count);
|
||||
|
||||
var items = shifts.Select(s => new ShiftListItemDto(
|
||||
s.Id,
|
||||
s.Title,
|
||||
s.StartTime,
|
||||
s.EndTime,
|
||||
s.Capacity,
|
||||
signupCounts.GetValueOrDefault(s.Id, 0)
|
||||
)).ToList();
|
||||
|
||||
return new ShiftListDto(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<ShiftDetailDto?> GetShiftByIdAsync(Guid id)
|
||||
{
|
||||
var shift = await _context.Shifts.FindAsync(id);
|
||||
|
||||
if (shift == null)
|
||||
return null;
|
||||
|
||||
var signups = await _context.ShiftSignups
|
||||
.Where(ss => ss.ShiftId == id)
|
||||
.OrderBy(ss => ss.SignedUpAt)
|
||||
.ToListAsync();
|
||||
|
||||
var signupDtos = signups.Select(ss => new ShiftSignupDto(
|
||||
ss.Id,
|
||||
ss.MemberId,
|
||||
ss.SignedUpAt
|
||||
)).ToList();
|
||||
|
||||
return new ShiftDetailDto(
|
||||
shift.Id,
|
||||
shift.Title,
|
||||
shift.Description,
|
||||
shift.Location,
|
||||
shift.StartTime,
|
||||
shift.EndTime,
|
||||
shift.Capacity,
|
||||
signupDtos,
|
||||
shift.ClubId,
|
||||
shift.CreatedById,
|
||||
shift.CreatedAt,
|
||||
shift.UpdatedAt
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<(ShiftDetailDto? shift, string? error)> CreateShiftAsync(CreateShiftRequest request, Guid createdById)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetTenantId();
|
||||
|
||||
var shift = new Shift
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Location = request.Location,
|
||||
StartTime = request.StartTime,
|
||||
EndTime = request.EndTime,
|
||||
Capacity = request.Capacity,
|
||||
ClubId = request.ClubId,
|
||||
CreatedById = createdById,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_context.Shifts.Add(shift);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var dto = new ShiftDetailDto(
|
||||
shift.Id,
|
||||
shift.Title,
|
||||
shift.Description,
|
||||
shift.Location,
|
||||
shift.StartTime,
|
||||
shift.EndTime,
|
||||
shift.Capacity,
|
||||
new List<ShiftSignupDto>(),
|
||||
shift.ClubId,
|
||||
shift.CreatedById,
|
||||
shift.CreatedAt,
|
||||
shift.UpdatedAt
|
||||
);
|
||||
|
||||
return (dto, null);
|
||||
}
|
||||
|
||||
public async Task<(ShiftDetailDto? shift, string? error, bool isConflict)> UpdateShiftAsync(Guid id, UpdateShiftRequest request)
|
||||
{
|
||||
var shift = await _context.Shifts.FindAsync(id);
|
||||
|
||||
if (shift == null)
|
||||
return (null, "Shift not found", false);
|
||||
|
||||
if (request.Title != null)
|
||||
shift.Title = request.Title;
|
||||
|
||||
if (request.Description != null)
|
||||
shift.Description = request.Description;
|
||||
|
||||
if (request.Location != null)
|
||||
shift.Location = request.Location;
|
||||
|
||||
if (request.StartTime.HasValue)
|
||||
shift.StartTime = request.StartTime.Value;
|
||||
|
||||
if (request.EndTime.HasValue)
|
||||
shift.EndTime = request.EndTime.Value;
|
||||
|
||||
if (request.Capacity.HasValue)
|
||||
shift.Capacity = request.Capacity.Value;
|
||||
|
||||
shift.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return (null, "Shift was modified by another user. Please refresh and try again.", true);
|
||||
}
|
||||
|
||||
var signups = await _context.ShiftSignups
|
||||
.Where(ss => ss.ShiftId == id)
|
||||
.OrderBy(ss => ss.SignedUpAt)
|
||||
.ToListAsync();
|
||||
|
||||
var signupDtos = signups.Select(ss => new ShiftSignupDto(
|
||||
ss.Id,
|
||||
ss.MemberId,
|
||||
ss.SignedUpAt
|
||||
)).ToList();
|
||||
|
||||
var dto = new ShiftDetailDto(
|
||||
shift.Id,
|
||||
shift.Title,
|
||||
shift.Description,
|
||||
shift.Location,
|
||||
shift.StartTime,
|
||||
shift.EndTime,
|
||||
shift.Capacity,
|
||||
signupDtos,
|
||||
shift.ClubId,
|
||||
shift.CreatedById,
|
||||
shift.CreatedAt,
|
||||
shift.UpdatedAt
|
||||
);
|
||||
|
||||
return (dto, null, false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteShiftAsync(Guid id)
|
||||
{
|
||||
var shift = await _context.Shifts.FindAsync(id);
|
||||
|
||||
if (shift == null)
|
||||
return false;
|
||||
|
||||
_context.Shifts.Remove(shift);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(bool success, string? error, bool isConflict)> SignUpForShiftAsync(Guid shiftId, Guid memberId)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetTenantId();
|
||||
|
||||
var shift = await _context.Shifts.FindAsync(shiftId);
|
||||
|
||||
if (shift == null)
|
||||
return (false, "Shift not found", false);
|
||||
|
||||
if (shift.StartTime <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return (false, "Cannot sign up for past shifts", false);
|
||||
}
|
||||
|
||||
var existingSignup = await _context.ShiftSignups
|
||||
.FirstOrDefaultAsync(ss => ss.ShiftId == shiftId && ss.MemberId == memberId);
|
||||
|
||||
if (existingSignup != null)
|
||||
{
|
||||
return (false, "Already signed up for this shift", true);
|
||||
}
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentSignups = await _context.ShiftSignups
|
||||
.Where(ss => ss.ShiftId == shiftId)
|
||||
.CountAsync();
|
||||
|
||||
if (currentSignups >= shift.Capacity)
|
||||
{
|
||||
return (false, "Shift is at full capacity", true);
|
||||
}
|
||||
|
||||
var signup = new ShiftSignup
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ShiftId = shiftId,
|
||||
MemberId = memberId,
|
||||
SignedUpAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_context.ShiftSignups.Add(signup);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return (true, null, false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) when (attempt == 0)
|
||||
{
|
||||
_context.Entry(shift).Reload();
|
||||
}
|
||||
}
|
||||
|
||||
return (false, "Shift capacity changed during sign-up", true);
|
||||
}
|
||||
|
||||
public async Task<(bool success, string? error)> CancelSignupAsync(Guid shiftId, Guid memberId)
|
||||
{
|
||||
var signup = await _context.ShiftSignups
|
||||
.FirstOrDefaultAsync(ss => ss.ShiftId == shiftId && ss.MemberId == memberId);
|
||||
|
||||
if (signup == null)
|
||||
{
|
||||
return (false, "Sign-up not found");
|
||||
}
|
||||
|
||||
_context.ShiftSignups.Remove(signup);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user