The signup/cancel endpoints were passing the Keycloak sub claim (external UUID) directly as MemberId, but ShiftSignup.MemberId references the internal Member.Id. Now ShiftService resolves ExternalUserId to the internal Member.Id before creating the signup record. Integration tests updated to seed proper Member entities.
300 lines
8.7 KiB
C#
300 lines
8.7 KiB
C#
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, string externalUserId)
|
|
{
|
|
var tenantId = _tenantProvider.GetTenantId();
|
|
|
|
var member = await _context.Members
|
|
.FirstOrDefaultAsync(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId);
|
|
|
|
if (member == null)
|
|
return (false, "Member not found", false);
|
|
|
|
var memberId = member.Id;
|
|
|
|
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, string externalUserId)
|
|
{
|
|
var tenantId = _tenantProvider.GetTenantId();
|
|
|
|
var member = await _context.Members
|
|
.FirstOrDefaultAsync(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId);
|
|
|
|
if (member == null)
|
|
return (false, "Member not found");
|
|
|
|
var signup = await _context.ShiftSignups
|
|
.FirstOrDefaultAsync(ss => ss.ShiftId == shiftId && ss.MemberId == member.Id);
|
|
|
|
if (signup == null)
|
|
{
|
|
return (false, "Sign-up not found");
|
|
}
|
|
|
|
_context.ShiftSignups.Remove(signup);
|
|
await _context.SaveChangesAsync();
|
|
|
|
return (true, null);
|
|
}
|
|
}
|