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:
WorkClub Automation
2026-03-03 19:30:23 +01:00
parent 8dfe32dc95
commit 0ef1d0bbd4
12 changed files with 1461 additions and 2 deletions

View 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);
}
}