5 Commits

Author SHA1 Message Date
WorkClub Automation ade9444682 Fix RLS permission issue in integration tests
- Add BYPASSRLS privilege to app_admin role
- Grant full schema and table access to app_admin
- Allow rls_test_user to assume app_admin role
- Fixes: permission denied for table clubs (42501)
2026-03-19 21:40:38 +01:00
WorkClub Automation 112b299b8e WIP: AdminClubService DI fix and RLS-related changes 2026-03-19 21:36:06 +01:00
WorkClub Automation 04641319ce feat: Add global administrator role support with integration tests for admin-only club endpoints. 2026-03-18 15:11:42 +01:00
WorkClub Automation d295c9123e feat: Configure Keycloak to use internal port 8081, explicitly define OIDC endpoints in NextAuth, and update API service Keycloak authority. 2026-03-18 14:47:57 +01:00
WorkClub Automation da70cf4b13 feat: Enrich DTOs and UI to display member names instead of UUIDs for task assignees, creators, and shift signups. 2026-03-18 14:15:33 +01:00
28 changed files with 2912 additions and 181 deletions
@@ -54,6 +54,14 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
return Task.FromResult(principal);
}
// --- NEW: Skip DB role lookup if user is a global admin ---
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess) && realmAccess.Contains("\"admin\"", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(principal);
}
// ---------------------------------------------------------
// Look up the user's role in the database for the requested tenant
_httpContextAccessor.HttpContext!.Items["TenantId"] = tenantId;
var memberRole = GetMemberRole(userIdClaim, tenantId);
@@ -44,6 +44,14 @@ public class TenantValidationMiddleware
if (string.IsNullOrEmpty(clubsClaim))
{
// NEW: Skip check if user is a global admin
var realmAccess = context.User.FindFirst("realm_access")?.Value;
if (!string.IsNullOrEmpty(realmAccess) && realmAccess.Contains("\"admin\"", StringComparison.OrdinalIgnoreCase))
{
await _next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "User does not have clubs claim" });
return;
+1 -1
View File
@@ -90,8 +90,8 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseMiddleware<TenantValidationMiddleware>();
app.UseAuthorization();
app.UseMiddleware<TenantValidationMiddleware>();
app.UseMiddleware<MemberSyncMiddleware>();
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
@@ -9,10 +9,12 @@ namespace WorkClub.Api.Services;
public class AdminClubService
{
private readonly AppDbContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
public AdminClubService(AppDbContext context)
public AdminClubService(AppDbContext context, IHttpContextAccessor httpContextAccessor)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<ClubDetailDto>> GetAllClubsAsync()
@@ -33,7 +35,15 @@ public class AdminClubService
public async Task<ClubDetailDto> CreateClubAsync(CreateClubRequest request)
{
var tenantId = Guid.NewGuid().ToString();
var tenantId = "club-" + Guid.NewGuid().ToString().Substring(0, 8);
// Ensure interceptors can see the new tenantId
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext != null)
{
httpContext.Items["TenantId"] = tenantId;
}
var club = new Club
{
Id = Guid.NewGuid(),
@@ -83,12 +83,13 @@ public class ShiftService
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 })
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.DisplayName,
ss.ExternalUserId,
ss.SignedUpAt
)).ToList();
@@ -194,12 +195,13 @@ public class ShiftService
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 })
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.DisplayName,
ss.ExternalUserId,
ss.SignedUpAt
)).ToList();
+55 -9
View File
@@ -38,23 +38,32 @@ public class TaskService
.Take(pageSize)
.ToListAsync();
Guid? memberId = null;
// Get current member ID for IsAssignedToMe check
Guid? currentMemberId = null;
if (currentExternalUserId != null)
{
var tenantId = _tenantProvider.GetTenantId();
memberId = await _context.Members
currentMemberId = await _context.Members
.Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId)
.Select(m => m.Id)
.FirstOrDefaultAsync();
}
// 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);
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,
memberId != null && w.AssigneeId == memberId
currentMemberId != null && w.AssigneeId == currentMemberId
)).ToList();
return new TaskListDto(itemDtos, total, page, pageSize);
@@ -67,28 +76,41 @@ public class TaskService
if (workItem == null)
return null;
Guid? memberId = null;
// Get current member ID for IsAssignedToMe check
Guid? currentMemberId = null;
if (currentExternalUserId != null)
{
var tenantId = _tenantProvider.GetTenantId();
memberId = await _context.Members
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<Guid>();
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,
memberId != null && workItem.AssigneeId == memberId
currentMemberId != null && workItem.AssigneeId == currentMemberId
);
}
@@ -114,13 +136,24 @@ public class TaskService
_context.WorkItems.Add(workItem);
await _context.SaveChangesAsync();
// Fetch creator and assignee names
var memberIds = new List<Guid> { createdById };
if (workItem.AssigneeId.HasValue) memberIds.Add(workItem.AssigneeId.Value);
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,
@@ -176,28 +209,41 @@ public class TaskService
return (null, "Task was modified by another user. Please refresh and try again.", true);
}
Guid? memberId = null;
// Get current member ID for IsAssignedToMe check
Guid? currentMemberId = null;
if (currentExternalUserId != null)
{
var tenantId = _tenantProvider.GetTenantId();
memberId = await _context.Members
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<Guid>();
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);
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,
memberId != null && workItem.AssigneeId == memberId
currentMemberId != null && workItem.AssigneeId == currentMemberId
);
return (dto, null, false);
@@ -18,6 +18,8 @@ public record ShiftDetailDto(
public record ShiftSignupDto(
Guid Id,
Guid MemberId, string? ExternalUserId,
Guid MemberId,
string? MemberName,
string? ExternalUserId,
DateTimeOffset SignedUpAt
);
@@ -6,7 +6,9 @@ public record TaskDetailDto(
string? Description,
string Status,
Guid? AssigneeId,
string? AssigneeName,
Guid CreatedById,
string? CreatedByName,
Guid ClubId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt,
@@ -12,6 +12,7 @@ public record TaskListItemDto(
string Title,
string Status,
Guid? AssigneeId,
string? AssigneeName,
DateTimeOffset CreatedAt,
bool IsAssignedToMe
);
@@ -185,11 +185,6 @@ public class TenantDbTransactionInterceptor : DbCommandInterceptor, IDbTransacti
{
var tenantId = _httpContextAccessor.HttpContext?.Items["TenantId"] as string;
if (string.IsNullOrWhiteSpace(tenantId)) return null;
if (!Guid.TryParse(tenantId, out _))
{
_logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId);
return null;
}
return tenantId;
}
@@ -0,0 +1,57 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using WorkClub.Domain.Enums;
using WorkClub.Application.Clubs.DTOs;
using WorkClub.Tests.Integration.Infrastructure;
using Xunit;
namespace WorkClub.Tests.Integration.Clubs;
public class AdminClubEndpointsTests : IntegrationTestBase
{
public AdminClubEndpointsTests(CustomWebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task CreateClub_WithAdminRole_ReturnsCreated()
{
AuthenticateAsAdmin();
var request = new CreateClubRequest("New Admin Club", SportType.Tennis, "Desc");
var response = await Client.PostAsJsonAsync("/api/admin/clubs", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
[Fact]
public async Task CreateClub_WithoutAdminRole_ReturnsForbidden()
{
AuthenticateAsNonAdmin();
var request = new CreateClubRequest("New Club", SportType.Tennis, "Desc");
var response = await Client.PostAsJsonAsync("/api/admin/clubs", request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
private void AuthenticateAsAdmin()
{
Client.DefaultRequestHeaders.Remove("X-Test-Email");
Client.DefaultRequestHeaders.Add("X-Test-Email", "admin@workclub.com");
Client.DefaultRequestHeaders.Remove("X-Test-Realm-Access");
Client.DefaultRequestHeaders.Add("X-Test-Realm-Access", "{\"roles\":[\"admin\"]}");
}
private void AuthenticateAsNonAdmin()
{
Client.DefaultRequestHeaders.Remove("X-Test-Email");
Client.DefaultRequestHeaders.Add("X-Test-Email", "user@workclub.com");
Client.DefaultRequestHeaders.Remove("X-Test-Realm-Access");
Client.DefaultRequestHeaders.Add("X-Test-Realm-Access", "{\"roles\":[\"user\"]}");
}
}
@@ -185,7 +185,7 @@ public class ClubEndpointsTests : IntegrationTestBase
}
[Fact]
public async Task GetClubsCurrent_NoTenantContext_ReturnsBadRequest()
public async Task GetClubsCurrent_NoTenantContext_ReturnsForbidden()
{
AuthenticateAs("admin@test.com", new Dictionary<string, string>
{
@@ -194,7 +194,7 @@ public class ClubEndpointsTests : IntegrationTestBase
var response = await Client.GetAsync("/api/clubs/current");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
@@ -62,12 +62,30 @@ public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProg
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
DO $$ BEGIN
-- Create test user for RLS
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rls_test_user') THEN
CREATE USER rls_test_user WITH PASSWORD 'rlspass';
END IF;
-- Grant basic permissions to test user
GRANT CONNECT ON DATABASE workclub_test TO rls_test_user;
GRANT USAGE ON SCHEMA public TO rls_test_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_test_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_test_user;
-- Create app_admin role for bypassing RLS
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_admin') THEN
CREATE ROLE app_admin WITH BYPASSRLS;
END IF;
-- Grant app_admin full access to tables
GRANT CONNECT ON DATABASE workclub_test TO app_admin;
GRANT USAGE ON SCHEMA public TO app_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_admin;
-- Allow rls_test_user to assume app_admin role
GRANT app_admin TO rls_test_user;
END $$;
";
cmd.ExecuteNonQuery();
@@ -30,9 +30,10 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
var emailClaim = Context.Request.Headers["X-Test-Email"].ToString();
var userIdClaim = Context.Request.Headers["X-Test-UserId"].ToString();
var clubRolesJson = Context.Request.Headers["X-Test-ClubRoles"].ToString();
var realmAccessClaim = Context.Request.Headers["X-Test-Realm-Access"].ToString();
// If no test auth headers are present, return NoResult (unauthenticated)
if (string.IsNullOrEmpty(emailClaim) && string.IsNullOrEmpty(userIdClaim) && string.IsNullOrEmpty(clubsClaim))
if (string.IsNullOrEmpty(emailClaim) && string.IsNullOrEmpty(userIdClaim) && string.IsNullOrEmpty(clubsClaim) && string.IsNullOrEmpty(realmAccessClaim))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
@@ -47,6 +48,11 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
new Claim("preferred_username", resolvedEmail),
};
if (!string.IsNullOrEmpty(realmAccessClaim))
{
claims.Add(new Claim("realm_access", realmAccessClaim, ClaimValueTypes.String));
}
if (!string.IsNullOrEmpty(clubsClaim))
{
claims.Add(new Claim("clubs", clubsClaim));
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15 -5
View File
@@ -39,8 +39,12 @@ 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"
KC_HTTP_PORT: "8081"
ports:
- "8080:8080"
- "8080:8081"
volumes:
- ./infra/keycloak:/opt/keycloak/data/import
depends_on:
@@ -59,18 +63,23 @@ services:
container_name: workclub_api
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:8080"
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=workclub;Username=workclub;Password=dev_password_change_in_production"
Keycloak__Authority: "http://keycloak:8080/realms/workclub"
Keycloak__Authority: "http://192.168.65.254:8080/realms/workclub"
Keycloak__Audience: "workclub-api"
Keycloak__TokenValidationParameters__ValidateIssuer: "false"
ports:
- "5001:8080"
extra_hosts:
- "localhost:host-gateway"
- "127.0.0.1:host-gateway"
- "keycloak:host-gateway"
working_dir: /app
volumes:
- ./backend:/app:cached
depends_on:
postgres:
condition: service_healthy
command: watch run WorkClub.Api/WorkClub.Api.csproj
networks:
- app-network
@@ -84,12 +93,13 @@ 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"
KEYCLOAK_ISSUER_INTERNAL: "http://keycloak:8080/realms/workclub"
KEYCLOAK_ISSUER_INTERNAL: "http://keycloak:8081/realms/workclub"
NEXT_PUBLIC_KEYCLOAK_ISSUER: "http://localhost:8080/realms/workclub"
ports:
- "3000:3000"
@@ -68,7 +68,7 @@ export default function ShiftDetailPage({ params }: { params: Promise<{ id: stri
) : (
<ul className="list-disc list-inside text-sm">
{shift.signups.map((signup) => (
<li key={signup.id}>Member ID: {signup.memberId}</li>
<li key={signup.id}>{signup.memberName || signup.memberId}</li>
))}
</ul>
)}
@@ -85,11 +85,11 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Assignee</p>
<p className="mt-1">{task.assigneeId || 'Unassigned'}</p>
<p className="mt-1">{task.assigneeName || 'Unassigned'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Created By</p>
<p className="mt-1">{task.createdById}</p>
<p className="mt-1">{task.createdByName || task.createdById}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Created At</p>
+1 -1
View File
@@ -89,7 +89,7 @@ export default function TaskListPage() {
{task.status}
</Badge>
</TableCell>
<TableCell>{task.assigneeId || 'Unassigned'}</TableCell>
<TableCell>{task.assigneeName || 'Unassigned'}</TableCell>
<TableCell>{new Date(task.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild>
+24 -2
View File
@@ -27,13 +27,12 @@ declare module "next-auth" {
const issuerPublic = process.env.KEYCLOAK_ISSUER!
const issuerInternal = process.env.KEYCLOAK_ISSUER_INTERNAL || issuerPublic
const oidcPublic = `${issuerPublic}/protocol/openid-connect`
const oidcInternal = `${issuerInternal}/protocol/openid-connect`
const oidcInternal = `${issuerInternal.replace(':8080', ':8081')}/protocol/openid-connect`
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: issuerPublic,
authorization: {
url: `${oidcPublic}/auth`,
@@ -41,8 +40,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
token: `${oidcInternal}/token`,
userinfo: `${oidcInternal}/userinfo`,
jwks_endpoint: `${oidcInternal}/certs`,
})
],
trustHost: true,
cookies: {
pkceCodeVerifier: {
name: "authjs.pkce.code_verifier",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: false,
},
},
state: {
name: "authjs.state",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: false,
},
},
},
debug: true,
callbacks: {
async jwt({ token, account }) {
if (account && account.access_token) {
+1
View File
@@ -38,6 +38,7 @@ export interface ShiftDetailDto {
export interface ShiftSignupDto {
id: string;
memberId: string;
memberName?: string;
externalUserId?: string;
signedUpAt: string;
}
+3
View File
@@ -14,6 +14,7 @@ export interface TaskListItemDto {
title: string;
status: string;
assigneeId: string | null;
assigneeName?: string;
createdAt: string;
isAssignedToMe: boolean;
}
@@ -24,7 +25,9 @@ export interface TaskDetailDto {
description: string | null;
status: string;
assigneeId: string | null;
assigneeName?: string;
createdById: string;
createdByName?: string;
clubId: string;
dueDate: string | null;
createdAt: string;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18
@@ -0,0 +1,99 @@
## Context
Currently, the frontend displays raw UUIDs for user references:
- Task list shows `assigneeId` (e.g., "a1b2c3d4-e5f6...") or "Unassigned"
- Task detail shows `assigneeId` and `createdById`
- Shift detail shows `memberId` for each signup
The backend already stores `DisplayName` in the `Member` entity but the API DTOs don't expose it. The `ShiftService` already demonstrates the pattern of joining with Members (lines 82-87), which we can replicate for Tasks.
## Goals / Non-Goals
**Goals:**
- Add member name fields to backend DTOs
- Update TaskService to query and include member names
- Update ShiftService to include member name in ShiftSignupDto
- Update frontend TypeScript interfaces
- Replace UUID displays with names in task/shift UIs
**Non-Goals:**
- No database schema changes
- No changes to authentication or authorization
- No changes to how tasks/shifts are created or updated
- No caching layer for member names
## Decisions
### 1. Add names to existing DTOs vs create new DTOs
**Decision:** Add optional fields to existing DTOs
**Rationale:**
- Keeps API surface simple
- Backward compatible - existing clients ignore new fields
- No breaking changes to existing integrations
**Alternative considered:** Create new DTO versions (e.g., `TaskDetailDtoV2`)
- Rejected: Unnecessary complexity for a simple additive change
### 2. Fetch member names via JOIN vs separate query
**Decision:** Use JOIN in TaskService methods
**Rationale:**
- More efficient - single query per endpoint
- Pattern already exists in ShiftService
- Avoids N+1 query problem
**Alternative considered:** Query members separately and build lookup dictionary
- Rejected: Adds complexity and extra database round-trips
### 3. Handle missing members (orphaned IDs)
**Decision:** Return null for name when member not found
**Rationale:**
- Data integrity issue should surface visibly
- Frontend can display fallback like "Unknown" or keep showing ID
- Logging can track data inconsistencies
### 4. Frontend handling of null names
**Decision:** Frontend shows fallback text when name is null
**Implementation:**
```typescript
// Task list
task.assigneeName || 'Unassigned'
// Task detail
task.assigneeName || 'Unassigned'
task.createdByName || 'Unknown'
// Shift signups
signup.memberName || 'Unknown Member'
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|-----------|
| JOIN adds query complexity | Keep JOINs simple, only on indexed columns (Member.Id) |
| Larger API response payloads | Minimal impact - names are small strings |
| Member names become stale | Acceptable - names rarely change; eventual consistency |
| Database performance degradation | Monitor query execution plans; add caching if needed |
| Partial data on member deletion | Show "Unknown" fallback; log orphaned references |
## Migration Plan
1. **Backend DTO changes** - Add new optional fields
2. **Backend service changes** - Update queries to include names
3. **Frontend type updates** - Add name fields to interfaces
4. **Frontend UI updates** - Replace ID displays with names
**Rollback:**
- DTO changes are backward compatible
- Frontend can revert to showing IDs by changing display logic
- No database changes required
## Open Questions
- Should we include `externalUserId` in the signup display? (Currently available in ShiftSignupDto)
- Do we need to include member email for any display purposes?
- Should we add name fields to shift list items (showing creator name)?
@@ -0,0 +1,34 @@
## Why
Currently, the frontend displays raw UUIDs for user references (assignee, creator, members) which creates a poor user experience. Users should see meaningful names like "Alice Smith" instead of "a1b2c3d4-e5f6-7890-abcd-ef1234567890". The backend already stores display names in the Member entity, but the API DTOs don't expose them.
## What Changes
- **Backend DTOs**: Add name fields to task and shift DTOs
- `TaskListItemDto`: Add `string? AssigneeName`
- `TaskDetailDto`: Add `string? AssigneeName` and `string CreatedByName`
- `ShiftSignupDto`: Add `string MemberName`
- **Backend Services**: Update TaskService and ShiftService to query and populate member names
- Join with Members table to fetch display names
- Include names in DTO construction
- **Frontend Types**: Update TypeScript interfaces to include new name fields
- `TaskListItemDto`, `TaskDetailDto`, `ShiftSignupDto` interfaces
- **Frontend UI**: Replace UUID displays with names
- Task list: show assignee name instead of ID
- Task detail: show assignee and creator names
- Shift detail: show member names in signup list
## Capabilities
### New Capabilities
- `member-name-enrichment`: API DTOs include human-readable member names alongside IDs
### Modified Capabilities
- None (this is purely an enhancement to existing capabilities)
## Impact
- **Backend**: TaskService.cs, ShiftService.cs, and DTOs in WorkClub.Application
- **Frontend**: Tasks pages, Shifts pages, and React hooks (useTasks.ts, useShifts.ts)
- **Database**: Additional JOIN queries on Members table (no schema changes)
- **API Response**: New optional fields in existing endpoints (backward compatible)
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Task list items include assignee name
The API SHALL return the assignee's display name in TaskListItemDto.
#### Scenario: Task with assignee
- **WHEN** a task is assigned to a member
- **THEN** the TaskListItemDto SHALL include the assignee's DisplayName as `assigneeName`
#### Scenario: Task without assignee
- **WHEN** a task has no assignee
- **THEN** the TaskListItemDto SHALL have `assigneeName` set to null
### Requirement: Task details include creator and assignee names
The API SHALL return the display names of both the creator and assignee in TaskDetailDto.
#### Scenario: Viewing task details
- **WHEN** a user requests task details
- **THEN** the TaskDetailDto SHALL include `createdByName` (the creator's DisplayName)
- **AND** the TaskDetailDto SHALL include `assigneeName` (the assignee's DisplayName, or null if unassigned)
### Requirement: Shift signup includes member name
The API SHALL return the member's display name in ShiftSignupDto.
#### Scenario: Viewing shift signups
- **WHEN** a user views shift details with signups
- **THEN** each ShiftSignupDto SHALL include `memberName` (the member's DisplayName)
### Requirement: Frontend displays names instead of UUIDs
The frontend SHALL render member names instead of UUIDs wherever user references appear.
#### Scenario: Task list view
- **WHEN** viewing the task list
- **THEN** the Assignee column SHALL display the assignee's name (or "Unassigned")
#### Scenario: Task detail view
- **WHEN** viewing a task detail page
- **THEN** the Assignee field SHALL display the assignee's name (or "Unassigned")
- **AND** the Created By field SHALL display the creator's name
#### Scenario: Shift detail view
- **WHEN** viewing a shift detail page with signups
- **THEN** the member list SHALL display each member's name instead of their ID
@@ -0,0 +1,41 @@
## 1. Backend DTO Updates
- [x] 1.1 Update TaskListItemDto.cs to add `string? AssigneeName` field
- [x] 1.2 Update TaskDetailDto.cs to add `string? AssigneeName` and `string? CreatedByName` fields
- [x] 1.3 Update ShiftSignupDto.cs to add `string? MemberName` field
## 2. Backend Service Updates - Tasks
- [x] 2.1 Update TaskService.GetTasksAsync() to join with Members and populate assigneeName
- [x] 2.2 Update TaskService.GetTaskByIdAsync() to join with Members for assignee and creator names
- [x] 2.3 Update TaskService.CreateTaskAsync() to fetch and include creator name in response
- [x] 2.4 Update TaskService.UpdateTaskAsync() to join with Members for assignee and creator names
## 3. Backend Service Updates - Shifts
- [x] 3.1 Update ShiftService.GetShiftByIdAsync() to include member display name in ShiftSignupDto
- [x] 3.2 Update ShiftService.UpdateShiftAsync() to include member display name in ShiftSignupDto
## 4. Frontend Type Updates
- [x] 4.1 Update TaskListItemDto interface in useTasks.ts to add `assigneeName?: string`
- [x] 4.2 Update TaskDetailDto interface in useTasks.ts to add `assigneeName?: string` and `createdByName?: string`
- [x] 4.3 Update ShiftSignupDto interface in useShifts.ts to add `memberName?: string`
## 5. Frontend UI Updates - Tasks
- [x] 5.1 Update tasks/page.tsx to display assigneeName instead of assigneeId
- [x] 5.2 Update tasks/[id]/page.tsx to display assigneeName instead of assigneeId
- [x] 5.3 Update tasks/[id]/page.tsx to display createdByName instead of createdById
## 6. Frontend UI Updates - Shifts
- [x] 6.1 Update shifts/[id]/page.tsx to display memberName instead of memberId in signup list
## 7. Testing & Verification
- [x] 7.1 Run backend build to verify C# compilation succeeds
- [x] 7.2 Run frontend build to verify TypeScript compilation succeeds
- [x] 7.3 Verify task list shows member names correctly
- [x] 7.4 Verify task detail shows assignee and creator names
- [x] 7.5 Verify shift detail shows member names in signup list