Fix task and shift self-assignment features
Some checks failed
CI Pipeline / Backend Build & Test (pull_request) Successful in 48s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Failing after 28s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 4s

This commit is contained in:
WorkClub Automation
2026-03-09 15:47:57 +01:00
parent 271b3c189c
commit 672dec5f21
17 changed files with 400 additions and 62 deletions

View File

@@ -42,20 +42,24 @@ public static class ShiftEndpoints
private static async Task<Ok<ShiftListDto>> GetShifts(
ShiftService shiftService,
HttpContext httpContext,
[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);
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
var result = await shiftService.GetShiftsAsync(from, to, page, pageSize, externalUserId);
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift(
Guid id,
ShiftService shiftService)
ShiftService shiftService,
HttpContext httpContext)
{
var result = await shiftService.GetShiftByIdAsync(id);
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
var result = await shiftService.GetShiftByIdAsync(id, externalUserId);
if (result == null)
return TypedResults.NotFound();
@@ -85,9 +89,11 @@ public static class ShiftEndpoints
private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift(
Guid id,
UpdateShiftRequest request,
ShiftService shiftService)
ShiftService shiftService,
HttpContext httpContext)
{
var (shift, error, isConflict) = await shiftService.UpdateShiftAsync(id, request);
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
var (shift, error, isConflict) = await shiftService.UpdateShiftAsync(id, request, externalUserId);
if (error != null)
{

View File

@@ -30,23 +30,35 @@ public static class TaskEndpoints
group.MapDelete("{id:guid}", DeleteTask)
.RequireAuthorization("RequireAdmin")
.WithName("DeleteTask");
group.MapPost("{id:guid}/assign", AssignTaskToMe)
.RequireAuthorization("RequireMember")
.WithName("AssignTaskToMe");
group.MapDelete("{id:guid}/assign", UnassignTaskFromMe)
.RequireAuthorization("RequireMember")
.WithName("UnassignTaskFromMe");
}
private static async Task<Ok<TaskListDto>> GetTasks(
TaskService taskService,
HttpContext httpContext,
[FromQuery] string? status = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await taskService.GetTasksAsync(status, page, pageSize);
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
var result = await taskService.GetTasksAsync(status, page, pageSize, externalUserId);
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<TaskDetailDto>, NotFound>> GetTask(
Guid id,
TaskService taskService)
TaskService taskService,
HttpContext httpContext)
{
var result = await taskService.GetTaskByIdAsync(id);
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
var result = await taskService.GetTaskByIdAsync(id, externalUserId);
if (result == null)
return TypedResults.NotFound();
@@ -76,9 +88,11 @@ public static class TaskEndpoints
private static async Task<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> UpdateTask(
Guid id,
UpdateTaskRequest request,
TaskService taskService)
TaskService taskService,
HttpContext httpContext)
{
var (task, error, isConflict) = await taskService.UpdateTaskAsync(id, request);
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
var (task, error, isConflict) = await taskService.UpdateTaskAsync(id, request, externalUserId);
if (error != null)
{
@@ -105,4 +119,42 @@ public static class TaskEndpoints
return TypedResults.NoContent();
}
private static async Task<Results<Ok, BadRequest<string>, NotFound>> AssignTaskToMe(
Guid id,
TaskService taskService,
HttpContext httpContext)
{
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
if (externalUserId == null) return TypedResults.BadRequest("Invalid user");
var (success, error) = await taskService.AssignToMeAsync(id, externalUserId);
if (!success)
{
if (error == "Task not found") return TypedResults.NotFound();
return TypedResults.BadRequest(error ?? "Failed to assign task");
}
return TypedResults.Ok();
}
private static async Task<Results<Ok, BadRequest<string>, NotFound>> UnassignTaskFromMe(
Guid id,
TaskService taskService,
HttpContext httpContext)
{
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
if (externalUserId == null) return TypedResults.BadRequest("Invalid user");
var (success, error) = await taskService.UnassignFromMeAsync(id, externalUserId);
if (!success)
{
if (error == "Task not found") return TypedResults.NotFound();
return TypedResults.BadRequest(error ?? "Failed to unassign task");
}
return TypedResults.Ok();
}
}

View File

@@ -17,7 +17,7 @@ public class ShiftService
_tenantProvider = tenantProvider;
}
public async Task<ShiftListDto> GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize)
public async Task<ShiftListDto> GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize, string? currentExternalUserId = null)
{
var query = _context.Shifts.AsQueryable();
@@ -42,19 +42,37 @@ public class ShiftService
.Select(g => new { ShiftId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ShiftId, x => x.Count);
var tenantId = _tenantProvider.GetTenantId();
var memberId = currentExternalUserId != null
? await _context.Members
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
.Select(m => (Guid?)m.Id)
.FirstOrDefaultAsync()
: null;
var userSignups = memberId.HasValue
? await _context.ShiftSignups
.Where(ss => shiftIds.Contains(ss.ShiftId) && ss.MemberId == memberId.Value)
.Select(ss => ss.ShiftId)
.ToListAsync()
: new List<Guid>();
var userSignedUpShiftIds = userSignups.ToHashSet();
var items = shifts.Select(s => new ShiftListItemDto(
s.Id,
s.Title,
s.StartTime,
s.EndTime,
s.Capacity,
signupCounts.GetValueOrDefault(s.Id, 0)
signupCounts.GetValueOrDefault(s.Id, 0),
userSignedUpShiftIds.Contains(s.Id)
)).ToList();
return new ShiftListDto(items, total, page, pageSize);
}
public async Task<ShiftDetailDto?> GetShiftByIdAsync(Guid id)
public async Task<ShiftDetailDto?> GetShiftByIdAsync(Guid id, string? currentExternalUserId = null)
{
var shift = await _context.Shifts.FindAsync(id);
@@ -75,6 +93,8 @@ public class ShiftService
ss.SignedUpAt
)).ToList();
var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId);
return new ShiftDetailDto(
shift.Id,
shift.Title,
@@ -87,7 +107,8 @@ public class ShiftService
shift.ClubId,
shift.CreatedById,
shift.CreatedAt,
shift.UpdatedAt
shift.UpdatedAt,
isSignedUp
);
}
@@ -126,13 +147,14 @@ public class ShiftService
shift.ClubId,
shift.CreatedById,
shift.CreatedAt,
shift.UpdatedAt
shift.UpdatedAt,
false
);
return (dto, null);
}
public async Task<(ShiftDetailDto? shift, string? error, bool isConflict)> UpdateShiftAsync(Guid id, UpdateShiftRequest request)
public async Task<(ShiftDetailDto? shift, string? error, bool isConflict)> UpdateShiftAsync(Guid id, UpdateShiftRequest request, string? currentExternalUserId = null)
{
var shift = await _context.Shifts.FindAsync(id);
@@ -182,6 +204,8 @@ public class ShiftService
ss.SignedUpAt
)).ToList();
var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId);
var dto = new ShiftDetailDto(
shift.Id,
shift.Title,
@@ -194,7 +218,8 @@ public class ShiftService
shift.ClubId,
shift.CreatedById,
shift.CreatedAt,
shift.UpdatedAt
shift.UpdatedAt,
isSignedUp
);
return (dto, null, false);

View File

@@ -18,7 +18,7 @@ public class TaskService
_tenantProvider = tenantProvider;
}
public async Task<TaskListDto> GetTasksAsync(string? statusFilter, int page, int pageSize)
public async Task<TaskListDto> GetTasksAsync(string? statusFilter, int page, int pageSize, string? currentExternalUserId = null)
{
var query = _context.WorkItems.AsQueryable();
@@ -38,24 +38,45 @@ public class TaskService
.Take(pageSize)
.ToListAsync();
Guid? memberId = null;
if (currentExternalUserId != null)
{
var tenantId = _tenantProvider.GetTenantId();
memberId = await _context.Members
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
.Select(m => m.Id)
.FirstOrDefaultAsync();
}
var itemDtos = items.Select(w => new TaskListItemDto(
w.Id,
w.Title,
w.Status.ToString(),
w.AssigneeId,
w.CreatedAt
w.CreatedAt,
memberId != null && w.AssigneeId == memberId
)).ToList();
return new TaskListDto(itemDtos, total, page, pageSize);
}
public async Task<TaskDetailDto?> GetTaskByIdAsync(Guid id)
public async Task<TaskDetailDto?> GetTaskByIdAsync(Guid id, string? currentExternalUserId = null)
{
var workItem = await _context.WorkItems.FindAsync(id);
if (workItem == null)
return null;
Guid? memberId = null;
if (currentExternalUserId != null)
{
var tenantId = _tenantProvider.GetTenantId();
memberId = await _context.Members
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
.Select(m => m.Id)
.FirstOrDefaultAsync();
}
return new TaskDetailDto(
workItem.Id,
workItem.Title,
@@ -66,7 +87,8 @@ public class TaskService
workItem.ClubId,
workItem.DueDate,
workItem.CreatedAt,
workItem.UpdatedAt
workItem.UpdatedAt,
memberId != null && workItem.AssigneeId == memberId
);
}
@@ -102,13 +124,14 @@ public class TaskService
workItem.ClubId,
workItem.DueDate,
workItem.CreatedAt,
workItem.UpdatedAt
workItem.UpdatedAt,
false
);
return (dto, null);
}
public async Task<(TaskDetailDto? task, string? error, bool isConflict)> UpdateTaskAsync(Guid id, UpdateTaskRequest request)
public async Task<(TaskDetailDto? task, string? error, bool isConflict)> UpdateTaskAsync(Guid id, UpdateTaskRequest request, string? currentExternalUserId = null)
{
var workItem = await _context.WorkItems.FindAsync(id);
@@ -153,6 +176,16 @@ public class TaskService
return (null, "Task was modified by another user. Please refresh and try again.", true);
}
Guid? memberId = null;
if (currentExternalUserId != null)
{
var tenantId = _tenantProvider.GetTenantId();
memberId = await _context.Members
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
.Select(m => m.Id)
.FirstOrDefaultAsync();
}
var dto = new TaskDetailDto(
workItem.Id,
workItem.Title,
@@ -163,7 +196,8 @@ public class TaskService
workItem.ClubId,
workItem.DueDate,
workItem.CreatedAt,
workItem.UpdatedAt
workItem.UpdatedAt,
memberId != null && workItem.AssigneeId == memberId
);
return (dto, null, false);
@@ -181,4 +215,81 @@ public class TaskService
return true;
}
public async Task<(bool success, string? error)> AssignToMeAsync(Guid taskId, string externalUserId)
{
var tenantId = _tenantProvider.GetTenantId();
var memberId = await _context.Members
.Where(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId)
.Select(m => m.Id)
.FirstOrDefaultAsync();
if (memberId == Guid.Empty)
return (false, "User is not a member of this club");
var workItem = await _context.WorkItems.FindAsync(taskId);
if (workItem == null)
return (false, "Task not found");
if (workItem.AssigneeId.HasValue)
return (false, "Task is already assigned");
workItem.AssigneeId = memberId;
if (workItem.CanTransitionTo(WorkItemStatus.Assigned))
workItem.TransitionTo(WorkItemStatus.Assigned);
workItem.UpdatedAt = DateTimeOffset.UtcNow;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return (false, "Task was modified by another user");
}
return (true, null);
}
public async Task<(bool success, string? error)> UnassignFromMeAsync(Guid taskId, string externalUserId)
{
var tenantId = _tenantProvider.GetTenantId();
var memberId = await _context.Members
.Where(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId)
.Select(m => m.Id)
.FirstOrDefaultAsync();
if (memberId == Guid.Empty)
return (false, "User is not a member of this club");
var workItem = await _context.WorkItems.FindAsync(taskId);
if (workItem == null)
return (false, "Task not found");
if (workItem.AssigneeId != memberId)
return (false, "Task is not assigned to you");
workItem.AssigneeId = null;
if (workItem.Status == WorkItemStatus.Assigned || workItem.Status == WorkItemStatus.InProgress)
{
// Transition back to open if no longer assigned and not marked Review/Done
workItem.Status = WorkItemStatus.Open;
}
workItem.UpdatedAt = DateTimeOffset.UtcNow;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return (false, "Task was modified by another user");
}
return (true, null);
}
}

View File

@@ -12,7 +12,8 @@ public record ShiftDetailDto(
Guid ClubId,
Guid CreatedById,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
DateTimeOffset UpdatedAt,
bool IsSignedUp
);
public record ShiftSignupDto(

View File

@@ -13,5 +13,6 @@ public record ShiftListItemDto(
DateTimeOffset StartTime,
DateTimeOffset EndTime,
int Capacity,
int CurrentSignups
int CurrentSignups,
bool IsSignedUp
);

View File

@@ -10,5 +10,6 @@ public record TaskDetailDto(
Guid ClubId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
DateTimeOffset UpdatedAt,
bool IsAssignedToMe
);

View File

@@ -12,5 +12,6 @@ public record TaskListItemDto(
string Title,
string Status,
Guid? AssigneeId,
DateTimeOffset CreatedAt
DateTimeOffset CreatedAt,
bool IsAssignedToMe
);

View File

@@ -198,9 +198,8 @@ public class ShiftCrudTests : IntegrationTestBase
{
// Arrange
var shiftId = Guid.NewGuid();
var clubId = Guid.NewGuid();
var (clubId, memberId, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
var createdBy = Guid.NewGuid();
var memberId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
@@ -236,7 +235,7 @@ public class ShiftCrudTests : IntegrationTestBase
}
SetTenant("tenant1");
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
// Act
var response = await Client.GetAsync($"/api/shifts/{shiftId}");
@@ -707,6 +706,6 @@ public class ShiftCrudTests : IntegrationTestBase
// Response DTOs for test assertions
public record ShiftListResponse(List<ShiftListItemResponse> Items, int Total, int Page, int PageSize);
public record ShiftListItemResponse(Guid Id, string Title, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, int CurrentSignups);
public record ShiftListItemResponse(Guid Id, string Title, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, int CurrentSignups, bool IsSignedUp);
public record ShiftDetailResponse(Guid Id, string Title, string? Description, string? Location, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, List<ShiftSignupResponse> Signups, Guid ClubId, Guid CreatedById, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
public record ShiftSignupResponse(Guid Id, Guid MemberId, DateTimeOffset SignedUpAt);