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( private static async Task<Ok<ShiftListDto>> GetShifts(
ShiftService shiftService, ShiftService shiftService,
HttpContext httpContext,
[FromQuery] DateTimeOffset? from = null, [FromQuery] DateTimeOffset? from = null,
[FromQuery] DateTimeOffset? to = null, [FromQuery] DateTimeOffset? to = null,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20) [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); return TypedResults.Ok(result);
} }
private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift( private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift(
Guid id, 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) if (result == null)
return TypedResults.NotFound(); return TypedResults.NotFound();
@@ -85,9 +89,11 @@ public static class ShiftEndpoints
private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift( private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift(
Guid id, Guid id,
UpdateShiftRequest request, 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) if (error != null)
{ {

View File

@@ -30,23 +30,35 @@ public static class TaskEndpoints
group.MapDelete("{id:guid}", DeleteTask) group.MapDelete("{id:guid}", DeleteTask)
.RequireAuthorization("RequireAdmin") .RequireAuthorization("RequireAdmin")
.WithName("DeleteTask"); .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( private static async Task<Ok<TaskListDto>> GetTasks(
TaskService taskService, TaskService taskService,
HttpContext httpContext,
[FromQuery] string? status = null, [FromQuery] string? status = null,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20) [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); return TypedResults.Ok(result);
} }
private static async Task<Results<Ok<TaskDetailDto>, NotFound>> GetTask( private static async Task<Results<Ok<TaskDetailDto>, NotFound>> GetTask(
Guid id, 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) if (result == null)
return TypedResults.NotFound(); return TypedResults.NotFound();
@@ -76,9 +88,11 @@ public static class TaskEndpoints
private static async Task<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> UpdateTask( private static async Task<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> UpdateTask(
Guid id, Guid id,
UpdateTaskRequest request, 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) if (error != null)
{ {
@@ -105,4 +119,42 @@ public static class TaskEndpoints
return TypedResults.NoContent(); 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; _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(); var query = _context.Shifts.AsQueryable();
@@ -42,19 +42,37 @@ public class ShiftService
.Select(g => new { ShiftId = g.Key, Count = g.Count() }) .Select(g => new { ShiftId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ShiftId, x => x.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( var items = shifts.Select(s => new ShiftListItemDto(
s.Id, s.Id,
s.Title, s.Title,
s.StartTime, s.StartTime,
s.EndTime, s.EndTime,
s.Capacity, s.Capacity,
signupCounts.GetValueOrDefault(s.Id, 0) signupCounts.GetValueOrDefault(s.Id, 0),
userSignedUpShiftIds.Contains(s.Id)
)).ToList(); )).ToList();
return new ShiftListDto(items, total, page, pageSize); 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); var shift = await _context.Shifts.FindAsync(id);
@@ -75,6 +93,8 @@ public class ShiftService
ss.SignedUpAt ss.SignedUpAt
)).ToList(); )).ToList();
var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId);
return new ShiftDetailDto( return new ShiftDetailDto(
shift.Id, shift.Id,
shift.Title, shift.Title,
@@ -87,7 +107,8 @@ public class ShiftService
shift.ClubId, shift.ClubId,
shift.CreatedById, shift.CreatedById,
shift.CreatedAt, shift.CreatedAt,
shift.UpdatedAt shift.UpdatedAt,
isSignedUp
); );
} }
@@ -126,13 +147,14 @@ public class ShiftService
shift.ClubId, shift.ClubId,
shift.CreatedById, shift.CreatedById,
shift.CreatedAt, shift.CreatedAt,
shift.UpdatedAt shift.UpdatedAt,
false
); );
return (dto, null); 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); var shift = await _context.Shifts.FindAsync(id);
@@ -182,6 +204,8 @@ public class ShiftService
ss.SignedUpAt ss.SignedUpAt
)).ToList(); )).ToList();
var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId);
var dto = new ShiftDetailDto( var dto = new ShiftDetailDto(
shift.Id, shift.Id,
shift.Title, shift.Title,
@@ -194,7 +218,8 @@ public class ShiftService
shift.ClubId, shift.ClubId,
shift.CreatedById, shift.CreatedById,
shift.CreatedAt, shift.CreatedAt,
shift.UpdatedAt shift.UpdatedAt,
isSignedUp
); );
return (dto, null, false); return (dto, null, false);

View File

@@ -18,7 +18,7 @@ public class TaskService
_tenantProvider = tenantProvider; _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(); var query = _context.WorkItems.AsQueryable();
@@ -38,24 +38,45 @@ public class TaskService
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .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( var itemDtos = items.Select(w => new TaskListItemDto(
w.Id, w.Id,
w.Title, w.Title,
w.Status.ToString(), w.Status.ToString(),
w.AssigneeId, w.AssigneeId,
w.CreatedAt w.CreatedAt,
memberId != null && w.AssigneeId == memberId
)).ToList(); )).ToList();
return new TaskListDto(itemDtos, total, page, pageSize); 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); var workItem = await _context.WorkItems.FindAsync(id);
if (workItem == null) if (workItem == null)
return 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( return new TaskDetailDto(
workItem.Id, workItem.Id,
workItem.Title, workItem.Title,
@@ -66,7 +87,8 @@ public class TaskService
workItem.ClubId, workItem.ClubId,
workItem.DueDate, workItem.DueDate,
workItem.CreatedAt, workItem.CreatedAt,
workItem.UpdatedAt workItem.UpdatedAt,
memberId != null && workItem.AssigneeId == memberId
); );
} }
@@ -102,13 +124,14 @@ public class TaskService
workItem.ClubId, workItem.ClubId,
workItem.DueDate, workItem.DueDate,
workItem.CreatedAt, workItem.CreatedAt,
workItem.UpdatedAt workItem.UpdatedAt,
false
); );
return (dto, null); 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); 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); 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( var dto = new TaskDetailDto(
workItem.Id, workItem.Id,
workItem.Title, workItem.Title,
@@ -163,7 +196,8 @@ public class TaskService
workItem.ClubId, workItem.ClubId,
workItem.DueDate, workItem.DueDate,
workItem.CreatedAt, workItem.CreatedAt,
workItem.UpdatedAt workItem.UpdatedAt,
memberId != null && workItem.AssigneeId == memberId
); );
return (dto, null, false); return (dto, null, false);
@@ -181,4 +215,81 @@ public class TaskService
return true; 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 ClubId,
Guid CreatedById, Guid CreatedById,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt DateTimeOffset UpdatedAt,
bool IsSignedUp
); );
public record ShiftSignupDto( public record ShiftSignupDto(

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export default function ShiftDetailPage({ params }: { params: Promise<{ id: stri
const capacityPercentage = (shift.signups.length / shift.capacity) * 100; const capacityPercentage = (shift.signups.length / shift.capacity) * 100;
const isFull = shift.signups.length >= shift.capacity; const isFull = shift.signups.length >= shift.capacity;
const isPast = new Date(shift.startTime) < new Date(); const isPast = new Date(shift.startTime) < new Date();
const isSignedUp = shift.signups.some((s) => s.memberId === session?.user?.id || s.externalUserId === session?.user?.id); const isSignedUp = shift.isSignedUp;
const handleSignUp = async () => { const handleSignUp = async () => {
await signUpMutation.mutateAsync(shift.id); await signUpMutation.mutateAsync(shift.id);

View File

@@ -2,7 +2,7 @@
import { use } from 'react'; import { use } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTask, useUpdateTask } from '@/hooks/useTasks'; import { useTask, useUpdateTask, useAssignTask, useUnassignTask } from '@/hooks/useTasks';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -26,9 +26,13 @@ const statusColors: Record<string, string> = {
export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params); const resolvedParams = use(params);
const { data: task, isLoading, error } = useTask(resolvedParams.id); const { data: task, isLoading, error } = useTask(resolvedParams.id);
const { mutate: updateTask, isPending } = useUpdateTask(); const { mutate: updateTask, isPending: isUpdating } = useUpdateTask();
const { mutate: assignTask, isPending: isAssigning } = useAssignTask();
const { mutate: unassignTask, isPending: isUnassigning } = useUnassignTask();
const { data: session } = useSession(); const { data: session } = useSession();
const isPending = isUpdating || isAssigning || isUnassigning;
if (isLoading) return <div className="p-8">Loading task...</div>; if (isLoading) return <div className="p-8">Loading task...</div>;
if (error || !task) return <div className="p-8 text-red-500">Failed to load task.</div>; if (error || !task) return <div className="p-8 text-red-500">Failed to load task.</div>;
@@ -39,9 +43,11 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
}; };
const handleAssignToMe = () => { const handleAssignToMe = () => {
if (session?.user?.id) { assignTask(task.id);
updateTask({ id: task.id, data: { assigneeId: session.user.id } }); };
}
const handleUnassign = () => {
unassignTask(task.id);
}; };
const getTransitionLabel = (status: string, newStatus: string) => { const getTransitionLabel = (status: string, newStatus: string) => {
@@ -107,7 +113,16 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
disabled={isPending} disabled={isPending}
variant="outline" variant="outline"
> >
{isPending ? 'Assigning...' : 'Assign to Me'} {isAssigning ? 'Assigning...' : 'Assign to Me'}
</Button>
)}
{task.isAssignedToMe && (
<Button
onClick={handleUnassign}
disabled={isPending}
variant="outline"
>
{isUnassigning ? 'Unassigning...' : 'Unassign'}
</Button> </Button>
)} )}
{validTransitions.map((nextStatus) => ( {validTransitions.map((nextStatus) => (

View File

@@ -1,8 +1,23 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { ShiftCard } from '../shifts/shift-card'; import { ShiftCard } from '../shifts/shift-card';
import { useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
vi.mock('@/hooks/useShifts', () => ({
useSignUpShift: vi.fn(),
useCancelSignUp: vi.fn(),
}));
describe('ShiftCard', () => { describe('ShiftCard', () => {
const mockSignUp = vi.fn();
const mockCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
(useSignUpShift as ReturnType<typeof vi.fn>).mockReturnValue({ mutate: mockSignUp, isPending: false });
(useCancelSignUp as ReturnType<typeof vi.fn>).mockReturnValue({ mutate: mockCancel, isPending: false });
});
it('shows capacity correctly (2/3 spots filled)', () => { it('shows capacity correctly (2/3 spots filled)', () => {
render( render(
<ShiftCard <ShiftCard
@@ -13,6 +28,7 @@ describe('ShiftCard', () => {
endTime: new Date(Date.now() + 200000).toISOString(), endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3, capacity: 3,
currentSignups: 2, currentSignups: 2,
isSignedUp: false,
}} }}
/> />
); );
@@ -29,6 +45,7 @@ describe('ShiftCard', () => {
endTime: new Date(Date.now() + 200000).toISOString(), endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3, capacity: 3,
currentSignups: 3, currentSignups: 3,
isSignedUp: false,
}} }}
/> />
); );
@@ -46,10 +63,28 @@ describe('ShiftCard', () => {
endTime: new Date(Date.now() - 100000).toISOString(), endTime: new Date(Date.now() - 100000).toISOString(),
capacity: 3, capacity: 3,
currentSignups: 1, currentSignups: 1,
isSignedUp: false,
}} }}
/> />
); );
expect(screen.getByText('Past')).toBeInTheDocument(); expect(screen.getByText('Past')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sign Up' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Sign Up' })).not.toBeInTheDocument();
}); });
it('shows cancel sign-up button when signed up', () => {
render(
<ShiftCard
shift={{
id: '1',
title: 'Signed Up Shift',
startTime: new Date(Date.now() + 100000).toISOString(),
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
currentSignups: 1,
isSignedUp: true,
}}
/>
);
expect(screen.getByText('Cancel Sign-up')).toBeInTheDocument();
});
}); });

View File

@@ -51,6 +51,7 @@ describe('ShiftDetailPage', () => {
endTime: new Date(Date.now() + 200000).toISOString(), endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3, capacity: 3,
signups: [{ id: 's1', memberId: 'other-user' }], signups: [{ id: 's1', memberId: 'other-user' }],
isSignedUp: false,
}, },
isLoading: false, isLoading: false,
}); });
@@ -77,6 +78,7 @@ describe('ShiftDetailPage', () => {
endTime: new Date(Date.now() + 200000).toISOString(), endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3, capacity: 3,
signups: [{ id: 's1', memberId: 'user-123' }], signups: [{ id: 's1', memberId: 'user-123' }],
isSignedUp: true,
}, },
isLoading: false, isLoading: false,
}); });
@@ -103,6 +105,7 @@ describe('ShiftDetailPage', () => {
endTime: new Date(Date.now() + 200000).toISOString(), endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3, capacity: 3,
signups: [], signups: [],
isSignedUp: false,
}, },
isLoading: false, isLoading: false,
}); });

View File

@@ -1,7 +1,7 @@
import { render, screen, act } from '@testing-library/react'; import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import TaskDetailPage from '@/app/(protected)/tasks/[id]/page'; import TaskDetailPage from '@/app/(protected)/tasks/[id]/page';
import { useTask, useUpdateTask } from '@/hooks/useTasks'; import { useTask, useUpdateTask, useAssignTask, useUnassignTask } from '@/hooks/useTasks';
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({ useRouter: vi.fn(() => ({
@@ -21,21 +21,34 @@ vi.mock('next-auth/react', () => ({
vi.mock('@/hooks/useTasks', () => ({ vi.mock('@/hooks/useTasks', () => ({
useTask: vi.fn(), useTask: vi.fn(),
useUpdateTask: vi.fn(), useUpdateTask: vi.fn(),
useAssignTask: vi.fn(),
useUnassignTask: vi.fn(),
})); }));
describe('TaskDetailPage', () => { describe('TaskDetailPage', () => {
const mockMutate = vi.fn(); const mockUpdate = vi.fn();
const mockAssign = vi.fn();
const mockUnassign = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({ (useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockMutate, mutate: mockUpdate,
isPending: false,
});
(useAssignTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockAssign,
isPending: false,
});
(useUnassignTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockUnassign,
isPending: false, isPending: false,
}); });
}); });
it('shows valid transitions for Open status', async () => { it('shows valid transitions for Open status', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01', isAssignedToMe: false },
isLoading: false, isLoading: false,
error: null, error: null,
}); });
@@ -52,7 +65,7 @@ describe('TaskDetailPage', () => {
it('shows valid transitions for InProgress status', async () => { it('shows valid transitions for InProgress status', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01', isAssignedToMe: false },
isLoading: false, isLoading: false,
error: null, error: null,
}); });
@@ -68,7 +81,7 @@ describe('TaskDetailPage', () => {
it('shows valid transitions for Review status (including back transition)', async () => { it('shows valid transitions for Review status (including back transition)', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01', isAssignedToMe: false },
isLoading: false, isLoading: false,
error: null, error: null,
}); });
@@ -91,7 +104,8 @@ describe('TaskDetailPage', () => {
assigneeId: null, assigneeId: null,
description: 'Desc', description: 'Desc',
createdAt: '2024-01-01', createdAt: '2024-01-01',
updatedAt: '2024-01-01' updatedAt: '2024-01-01',
isAssignedToMe: false
}, },
isLoading: false, isLoading: false,
error: null, error: null,
@@ -105,8 +119,7 @@ describe('TaskDetailPage', () => {
expect(screen.getByText('Assign to Me')).toBeInTheDocument(); expect(screen.getByText('Assign to Me')).toBeInTheDocument();
}); });
it('calls updateTask with assigneeId when Assign to Me clicked', async () => { it('calls assignTask with task id when Assign to Me clicked', async () => {
const mockMutate = vi.fn();
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { data: {
id: '1', id: '1',
@@ -115,15 +128,12 @@ describe('TaskDetailPage', () => {
assigneeId: null, assigneeId: null,
description: 'Desc', description: 'Desc',
createdAt: '2024-01-01', createdAt: '2024-01-01',
updatedAt: '2024-01-01' updatedAt: '2024-01-01',
isAssignedToMe: false
}, },
isLoading: false, isLoading: false,
error: null, error: null,
}); });
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockMutate,
isPending: false,
});
const params = Promise.resolve({ id: '1' }); const params = Promise.resolve({ id: '1' });
await act(async () => { await act(async () => {
@@ -135,9 +145,37 @@ describe('TaskDetailPage', () => {
button.click(); button.click();
}); });
expect(mockMutate).toHaveBeenCalledWith({ expect(mockAssign).toHaveBeenCalledWith('1');
id: '1', });
data: { assigneeId: 'user-123' },
it('renders Unassign button and calls unassignTask when clicked', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
id: '1',
title: 'Task 1',
status: 'Assigned',
assigneeId: 'some-member-id',
description: 'Desc',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
isAssignedToMe: true
},
isLoading: false,
error: null,
}); });
const params = Promise.resolve({ id: '1' });
await act(async () => {
render(<TaskDetailPage params={params} />);
});
const button = screen.getByText('Unassign');
expect(button).toBeInTheDocument();
await act(async () => {
button.click();
});
expect(mockUnassign).toHaveBeenCalledWith('1');
}); });
}); });

View File

@@ -3,13 +3,16 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ShiftListItemDto } from '@/hooks/useShifts'; import { ShiftListItemDto, useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
interface ShiftCardProps { interface ShiftCardProps {
shift: ShiftListItemDto; shift: ShiftListItemDto;
} }
export function ShiftCard({ shift }: ShiftCardProps) { export function ShiftCard({ shift }: ShiftCardProps) {
const signUpMutation = useSignUpShift();
const cancelMutation = useCancelSignUp();
const capacityPercentage = (shift.currentSignups / shift.capacity) * 100; const capacityPercentage = (shift.currentSignups / shift.capacity) * 100;
const isFull = shift.currentSignups >= shift.capacity; const isFull = shift.currentSignups >= shift.capacity;
const isPast = new Date(shift.startTime) < new Date(); const isPast = new Date(shift.startTime) < new Date();
@@ -39,8 +42,15 @@ export function ShiftCard({ shift }: ShiftCardProps) {
<Link href={`/shifts/${shift.id}`}> <Link href={`/shifts/${shift.id}`}>
<Button variant="outline" size="sm">View Details</Button> <Button variant="outline" size="sm">View Details</Button>
</Link> </Link>
{!isPast && !isFull && ( {!isPast && !isFull && !shift.isSignedUp && (
<Button size="sm">Sign Up</Button> <Button size="sm" onClick={() => signUpMutation.mutate(shift.id)} disabled={signUpMutation.isPending}>
{signUpMutation.isPending ? 'Signing up...' : 'Sign Up'}
</Button>
)}
{!isPast && shift.isSignedUp && (
<Button variant="outline" size="sm" onClick={() => cancelMutation.mutate(shift.id)} disabled={cancelMutation.isPending}>
{cancelMutation.isPending ? 'Canceling...' : 'Cancel Sign-up'}
</Button>
)} )}
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ export interface ShiftListItemDto {
endTime: string; endTime: string;
capacity: number; capacity: number;
currentSignups: number; currentSignups: number;
isSignedUp: boolean;
} }
export interface ShiftDetailDto { export interface ShiftDetailDto {
@@ -31,6 +32,7 @@ export interface ShiftDetailDto {
createdById: string; createdById: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isSignedUp: boolean;
} }
export interface ShiftSignupDto { export interface ShiftSignupDto {

View File

@@ -11,10 +11,9 @@ export interface TaskListDto {
export interface TaskListItemDto { export interface TaskListItemDto {
id: string; id: string;
title: string;
status: string;
assigneeId: string | null; assigneeId: string | null;
createdAt: string; createdAt: string;
isAssignedToMe: boolean;
} }
export interface TaskDetailDto { export interface TaskDetailDto {
@@ -28,6 +27,7 @@ export interface TaskDetailDto {
dueDate: string | null; dueDate: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isAssignedToMe: boolean;
} }
export interface CreateTaskRequest { export interface CreateTaskRequest {
@@ -120,3 +120,41 @@ export function useUpdateTask() {
}, },
}); });
} }
export function useAssignTask() {
const queryClient = useQueryClient();
const { activeClubId } = useTenant();
return useMutation({
mutationFn: async (id: string) => {
const res = await apiClient(`/api/tasks/${id}/assign`, {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to assign task');
return res;
},
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId, id] });
},
});
}
export function useUnassignTask() {
const queryClient = useQueryClient();
const { activeClubId } = useTenant();
return useMutation({
mutationFn: async (id: string) => {
const res = await apiClient(`/api/tasks/${id}/assign`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to unassign task');
return res;
},
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId, id] });
},
});
}