From da70cf4b136b98964da52aaa57734b33d559c74e Mon Sep 17 00:00:00 2001 From: WorkClub Automation Date: Wed, 18 Mar 2026 14:15:33 +0100 Subject: [PATCH] feat: Enrich DTOs and UI to display member names instead of UUIDs for task assignees, creators, and shift signups. --- backend/WorkClub.Api/Services/ShiftService.cs | 62 ++--- backend/WorkClub.Api/Services/TaskService.cs | 232 +++++++++++------- .../Shifts/DTOs/ShiftDetailDto.cs | 8 +- .../Tasks/DTOs/TaskDetailDto.cs | 24 +- .../Tasks/DTOs/TaskListDto.cs | 13 +- docker-compose.yml | 8 +- .../src/app/(protected)/shifts/[id]/page.tsx | 6 +- .../src/app/(protected)/tasks/[id]/page.tsx | 4 +- frontend/src/app/(protected)/tasks/page.tsx | 2 +- frontend/src/auth/auth.ts | 28 ++- frontend/src/hooks/useShifts.ts | 1 + frontend/src/hooks/useTasks.ts | 3 + .../replace-uuids-with-names/.openspec.yaml | 2 + .../replace-uuids-with-names/design.md | 99 ++++++++ .../replace-uuids-with-names/proposal.md | 34 +++ .../specs/member-name-enrichment/spec.md | 43 ++++ .../changes/replace-uuids-with-names/tasks.md | 41 ++++ 17 files changed, 453 insertions(+), 157 deletions(-) create mode 100644 openspec/changes/replace-uuids-with-names/.openspec.yaml create mode 100644 openspec/changes/replace-uuids-with-names/design.md create mode 100644 openspec/changes/replace-uuids-with-names/proposal.md create mode 100644 openspec/changes/replace-uuids-with-names/specs/member-name-enrichment/spec.md create mode 100644 openspec/changes/replace-uuids-with-names/tasks.md diff --git a/backend/WorkClub.Api/Services/ShiftService.cs b/backend/WorkClub.Api/Services/ShiftService.cs index 6d5954a..d4584c0 100644 --- a/backend/WorkClub.Api/Services/ShiftService.cs +++ b/backend/WorkClub.Api/Services/ShiftService.cs @@ -76,26 +76,27 @@ public class ShiftService { var shift = await _context.Shifts.FindAsync(id); - if (shift == null) - return null; + if (shift == null) + return null; - var signups = await (from ss in _context.ShiftSignups - where ss.ShiftId == id - join m in _context.Members on ss.MemberId equals m.Id - orderby ss.SignedUpAt - select new { ss.Id, ss.MemberId, m.ExternalUserId, ss.SignedUpAt }) - .ToListAsync(); + var signups = await (from ss in _context.ShiftSignups + where ss.ShiftId == id + join m in _context.Members on ss.MemberId equals m.Id + orderby ss.SignedUpAt + select new { ss.Id, ss.MemberId, m.DisplayName, m.ExternalUserId, ss.SignedUpAt }) + .ToListAsync(); - var signupDtos = signups.Select(ss => new ShiftSignupDto( - ss.Id, - ss.MemberId, - ss.ExternalUserId, - ss.SignedUpAt - )).ToList(); + var signupDtos = signups.Select(ss => new ShiftSignupDto( + ss.Id, + ss.MemberId, + ss.DisplayName, + ss.ExternalUserId, + ss.SignedUpAt + )).ToList(); - var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId); + var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId); - return new ShiftDetailDto( + return new ShiftDetailDto( shift.Id, shift.Title, shift.Description, @@ -190,23 +191,24 @@ public class ShiftService return (null, "Shift was modified by another user. Please refresh and try again.", true); } - var signups = await (from ss in _context.ShiftSignups - where ss.ShiftId == id - join m in _context.Members on ss.MemberId equals m.Id - orderby ss.SignedUpAt - select new { ss.Id, ss.MemberId, m.ExternalUserId, ss.SignedUpAt }) - .ToListAsync(); + var signups = await (from ss in _context.ShiftSignups + where ss.ShiftId == id + join m in _context.Members on ss.MemberId equals m.Id + orderby ss.SignedUpAt + select new { ss.Id, ss.MemberId, m.DisplayName, m.ExternalUserId, ss.SignedUpAt }) + .ToListAsync(); - var signupDtos = signups.Select(ss => new ShiftSignupDto( - ss.Id, - ss.MemberId, - ss.ExternalUserId, - ss.SignedUpAt - )).ToList(); + var signupDtos = signups.Select(ss => new ShiftSignupDto( + ss.Id, + ss.MemberId, + ss.DisplayName, + ss.ExternalUserId, + ss.SignedUpAt + )).ToList(); - var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId); + var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId); - var dto = new ShiftDetailDto( + var dto = new ShiftDetailDto( shift.Id, shift.Title, shift.Description, diff --git a/backend/WorkClub.Api/Services/TaskService.cs b/backend/WorkClub.Api/Services/TaskService.cs index 797ac7c..5b17fbf 100644 --- a/backend/WorkClub.Api/Services/TaskService.cs +++ b/backend/WorkClub.Api/Services/TaskService.cs @@ -30,67 +30,89 @@ public class TaskService } } - var total = await query.CountAsync(); + var total = await query.CountAsync(); - var items = await query - .OrderBy(w => w.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); + var items = await query + .OrderBy(w => w.CreatedAt) + .Skip((page - 1) * pageSize) + .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(); - } + // Get current member ID for IsAssignedToMe check + Guid? currentMemberId = null; + if (currentExternalUserId != null) + { + var tenantId = _tenantProvider.GetTenantId(); + currentMemberId = 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, - memberId != null && w.AssigneeId == memberId - )).ToList(); + // Get all assignee IDs to fetch their names in bulk + var assigneeIds = items.Where(w => w.AssigneeId.HasValue).Select(w => w.AssigneeId!.Value).Distinct().ToList(); + var assigneeNames = await _context.Members + .Where(m => assigneeIds.Contains(m.Id)) + .Select(m => new { m.Id, m.DisplayName }) + .ToDictionaryAsync(m => m.Id, m => m.DisplayName); - return new TaskListDto(itemDtos, total, page, pageSize); - } + var itemDtos = items.Select(w => new TaskListItemDto( + w.Id, + w.Title, + w.Status.ToString(), + w.AssigneeId, + w.AssigneeId.HasValue && assigneeNames.TryGetValue(w.AssigneeId.Value, out var name) ? name : null, + w.CreatedAt, + currentMemberId != null && w.AssigneeId == currentMemberId + )).ToList(); - public async Task GetTaskByIdAsync(Guid id, string? currentExternalUserId = null) - { - var workItem = await _context.WorkItems.FindAsync(id); + return new TaskListDto(itemDtos, total, page, pageSize); +} - if (workItem == null) - return null; +public async Task GetTaskByIdAsync(Guid id, string? currentExternalUserId = null) +{ + var workItem = await _context.WorkItems.FindAsync(id); - 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(); - } + if (workItem == null) + return null; - return new TaskDetailDto( - workItem.Id, - workItem.Title, - workItem.Description, - workItem.Status.ToString(), - workItem.AssigneeId, - workItem.CreatedById, - workItem.ClubId, - workItem.DueDate, - workItem.CreatedAt, - workItem.UpdatedAt, - memberId != null && workItem.AssigneeId == memberId - ); - } + // Get current member ID for IsAssignedToMe check + Guid? currentMemberId = null; + if (currentExternalUserId != null) + { + var tenantId = _tenantProvider.GetTenantId(); + currentMemberId = await _context.Members + .Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + } + + // Fetch assignee and creator names + var memberIds = new List(); + if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value); + memberIds.Add(workItem.CreatedById); + + var memberNames = await _context.Members + .Where(m => memberIds.Contains(m.Id)) + .Select(m => new { m.Id, m.DisplayName }) + .ToDictionaryAsync(m => m.Id, m => m.DisplayName); + + return new TaskDetailDto( + workItem.Id, + workItem.Title, + workItem.Description, + workItem.Status.ToString(), + workItem.AssigneeId, + workItem.AssigneeId.HasValue && memberNames.TryGetValue(workItem.AssigneeId.Value, out var assigneeName) ? assigneeName : null, + workItem.CreatedById, + memberNames.TryGetValue(workItem.CreatedById, out var createdByName) ? createdByName : null, + workItem.ClubId, + workItem.DueDate, + workItem.CreatedAt, + workItem.UpdatedAt, + currentMemberId != null && workItem.AssigneeId == currentMemberId + ); +} public async Task<(TaskDetailDto? task, string? error)> CreateTaskAsync(CreateTaskRequest request, Guid createdById) { @@ -111,24 +133,35 @@ public class TaskService UpdatedAt = DateTimeOffset.UtcNow }; - _context.WorkItems.Add(workItem); - await _context.SaveChangesAsync(); + _context.WorkItems.Add(workItem); + await _context.SaveChangesAsync(); - var dto = new TaskDetailDto( - workItem.Id, - workItem.Title, - workItem.Description, - workItem.Status.ToString(), - workItem.AssigneeId, - workItem.CreatedById, - workItem.ClubId, - workItem.DueDate, - workItem.CreatedAt, - workItem.UpdatedAt, - false - ); + // Fetch creator and assignee names + var memberIds = new List { createdById }; + if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value); - return (dto, null); + var memberNames = await _context.Members + .Where(m => memberIds.Contains(m.Id)) + .Select(m => new { m.Id, m.DisplayName }) + .ToDictionaryAsync(m => m.Id, m => m.DisplayName); + + var dto = new TaskDetailDto( + workItem.Id, + workItem.Title, + workItem.Description, + workItem.Status.ToString(), + workItem.AssigneeId, + workItem.AssigneeId.HasValue && memberNames.TryGetValue(workItem.AssigneeId.Value, out var assigneeName) ? assigneeName : null, + workItem.CreatedById, + memberNames.TryGetValue(workItem.CreatedById, out var createdByName) ? createdByName : null, + workItem.ClubId, + workItem.DueDate, + workItem.CreatedAt, + workItem.UpdatedAt, + false + ); + + return (dto, null); } public async Task<(TaskDetailDto? task, string? error, bool isConflict)> UpdateTaskAsync(Guid id, UpdateTaskRequest request, string? currentExternalUserId = null) @@ -176,32 +209,45 @@ 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(); - } + // Get current member ID for IsAssignedToMe check + Guid? currentMemberId = null; + if (currentExternalUserId != null) + { + var tenantId = _tenantProvider.GetTenantId(); + currentMemberId = await _context.Members + .Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + } - var dto = new TaskDetailDto( - workItem.Id, - workItem.Title, - workItem.Description, - workItem.Status.ToString(), - workItem.AssigneeId, - workItem.CreatedById, - workItem.ClubId, - workItem.DueDate, - workItem.CreatedAt, - workItem.UpdatedAt, - memberId != null && workItem.AssigneeId == memberId - ); + // Fetch assignee and creator names + var memberIds = new List(); + if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value); + memberIds.Add(workItem.CreatedById); - return (dto, null, false); - } + var memberNames = await _context.Members + .Where(m => memberIds.Contains(m.Id)) + .Select(m => new { m.Id, m.DisplayName }) + .ToDictionaryAsync(m => m.Id, m => m.DisplayName); + + var dto = new TaskDetailDto( + workItem.Id, + workItem.Title, + workItem.Description, + workItem.Status.ToString(), + workItem.AssigneeId, + workItem.AssigneeId.HasValue && memberNames.TryGetValue(workItem.AssigneeId.Value, out var assigneeName) ? assigneeName : null, + workItem.CreatedById, + memberNames.TryGetValue(workItem.CreatedById, out var createdByName) ? createdByName : null, + workItem.ClubId, + workItem.DueDate, + workItem.CreatedAt, + workItem.UpdatedAt, + currentMemberId != null && workItem.AssigneeId == currentMemberId + ); + + return (dto, null, false); +} public async Task DeleteTaskAsync(Guid id) { diff --git a/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs b/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs index 4e450e9..29457af 100644 --- a/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs +++ b/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs @@ -17,7 +17,9 @@ public record ShiftDetailDto( ); public record ShiftSignupDto( - Guid Id, - Guid MemberId, string? ExternalUserId, - DateTimeOffset SignedUpAt + Guid Id, + Guid MemberId, + string? MemberName, + string? ExternalUserId, + DateTimeOffset SignedUpAt ); diff --git a/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs b/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs index e05dc7f..e7e19fe 100644 --- a/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs +++ b/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs @@ -1,15 +1,17 @@ namespace WorkClub.Application.Tasks.DTOs; public record TaskDetailDto( - Guid Id, - string Title, - string? Description, - string Status, - Guid? AssigneeId, - Guid CreatedById, - Guid ClubId, - DateTimeOffset? DueDate, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt, - bool IsAssignedToMe + Guid Id, + string Title, + string? Description, + string Status, + Guid? AssigneeId, + string? AssigneeName, + Guid CreatedById, + string? CreatedByName, + Guid ClubId, + DateTimeOffset? DueDate, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + bool IsAssignedToMe ); diff --git a/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs b/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs index 0c75d7f..c56d2e6 100644 --- a/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs +++ b/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs @@ -8,10 +8,11 @@ public record TaskListDto( ); public record TaskListItemDto( - Guid Id, - string Title, - string Status, - Guid? AssigneeId, - DateTimeOffset CreatedAt, - bool IsAssignedToMe + Guid Id, + string Title, + string Status, + Guid? AssigneeId, + string? AssigneeName, + DateTimeOffset CreatedAt, + bool IsAssignedToMe ); diff --git a/docker-compose.yml b/docker-compose.yml index dd2300a..480ec44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,9 @@ services: KC_DB_PASSWORD: keycloakpass KC_HEALTH_ENABLED: "true" KC_LOG_LEVEL: INFO + KC_HOSTNAME: "http://localhost:8080" + KC_HOSTNAME_STRICT: "false" + KC_PROXY: "edge" ports: - "8080:8080" volumes: @@ -66,6 +69,8 @@ services: Keycloak__TokenValidationParameters__ValidateIssuer: "false" ports: - "5001:8080" + extra_hosts: + - "localhost:host-gateway" volumes: - ./backend:/app:cached depends_on: @@ -84,8 +89,9 @@ services: environment: NEXT_PUBLIC_API_URL: "http://localhost:5001" API_INTERNAL_URL: "http://dotnet-api:8080" - NEXTAUTH_URL: "http://localhost:3000" NEXTAUTH_SECRET: "dev-secret-change-in-production-use-openssl-rand-base64-32" + AUTH_SECRET: "dev-secret-change-in-production-use-openssl-rand-base64-32" + AUTH_TRUST_HOST: "true" KEYCLOAK_CLIENT_ID: "workclub-app" KEYCLOAK_CLIENT_SECRET: "dev-secret-workclub-api-change-in-production" KEYCLOAK_ISSUER: "http://localhost:8080/realms/workclub" diff --git a/frontend/src/app/(protected)/shifts/[id]/page.tsx b/frontend/src/app/(protected)/shifts/[id]/page.tsx index 71a2e17..694ba9d 100644 --- a/frontend/src/app/(protected)/shifts/[id]/page.tsx +++ b/frontend/src/app/(protected)/shifts/[id]/page.tsx @@ -67,9 +67,9 @@ export default function ShiftDetailPage({ params }: { params: Promise<{ id: stri

No sign-ups yet

) : (
    - {shift.signups.map((signup) => ( -
  • Member ID: {signup.memberId}
  • - ))} +{shift.signups.map((signup) => ( +
  • {signup.memberName || signup.memberId}
  • + ))}
)} diff --git a/frontend/src/app/(protected)/tasks/[id]/page.tsx b/frontend/src/app/(protected)/tasks/[id]/page.tsx index d28a279..2a96e1b 100644 --- a/frontend/src/app/(protected)/tasks/[id]/page.tsx +++ b/frontend/src/app/(protected)/tasks/[id]/page.tsx @@ -85,11 +85,11 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin

Assignee

-

{task.assigneeId || 'Unassigned'}

+

{task.assigneeName || 'Unassigned'}

Created By

-

{task.createdById}

+

{task.createdByName || task.createdById}

Created At

diff --git a/frontend/src/app/(protected)/tasks/page.tsx b/frontend/src/app/(protected)/tasks/page.tsx index 417b61c..0615090 100644 --- a/frontend/src/app/(protected)/tasks/page.tsx +++ b/frontend/src/app/(protected)/tasks/page.tsx @@ -89,7 +89,7 @@ export default function TaskListPage() { {task.status} - {task.assigneeId || 'Unassigned'} + {task.assigneeName || 'Unassigned'} {new Date(task.createdAt).toLocaleDateString()}