feat(clubs): add Club and Member API endpoints with auto-sync

Implement Task 16: Club + Member API endpoints with MemberSyncService

Services:
- ClubService: GetMyClubsAsync (user's clubs), GetCurrentClubAsync (tenant club)
- MemberService: GetMembersAsync (list), GetMemberByIdAsync, GetCurrentMemberAsync
- MemberSyncService: Auto-creates Member records from JWT on first request

Middleware:
- MemberSyncMiddleware: Runs after auth, calls MemberSyncService

Endpoints:
- GET /api/clubs/me (list user's clubs)
- GET /api/clubs/current (current tenant's club)
- GET /api/members (list members, RLS filtered)
- GET /api/members/{id} (member detail)
- GET /api/members/me (current user's membership)

Tests: 14 integration tests (6 club + 8 member)
- Club filtering by user membership
- Multi-tenant isolation via RLS
- Member auto-sync on first request
- Cross-tenant access blocked
- Role-based authorization

Build: 0 errors, all tests compile
Pattern: TypedResults, RequireAuthorization policies, TDD approach
This commit is contained in:
WorkClub Automation
2026-03-03 19:41:01 +01:00
parent 0ef1d0bbd4
commit db880b3480
15 changed files with 1036 additions and 1 deletions

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Clubs.DTOs;
using WorkClub.Application.Interfaces;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class ClubService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
public ClubService(
AppDbContext context,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor)
{
_context = context;
_tenantProvider = tenantProvider;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<ClubListDto>> GetMyClubsAsync()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
return new List<ClubListDto>();
}
var memberships = await _context.Members
.Where(m => m.ExternalUserId == userIdClaim)
.ToListAsync();
var clubIds = memberships.Select(m => m.ClubId).ToList();
var clubs = await _context.Clubs
.Where(c => clubIds.Contains(c.Id))
.ToListAsync();
var clubDtos = new List<ClubListDto>();
foreach (var club in clubs)
{
var memberCount = await _context.Members
.Where(m => m.ClubId == club.Id)
.CountAsync();
clubDtos.Add(new ClubListDto(
club.Id,
club.Name,
club.SportType.ToString(),
memberCount
));
}
return clubDtos;
}
public async Task<ClubDetailDto?> GetCurrentClubAsync()
{
var tenantId = _tenantProvider.GetTenantId();
var club = await _context.Clubs
.FirstOrDefaultAsync(c => c.TenantId == tenantId);
if (club == null)
return null;
return new ClubDetailDto(
club.Id,
club.Name,
club.SportType.ToString(),
club.Description,
club.CreatedAt,
club.UpdatedAt
);
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Interfaces;
using WorkClub.Application.Members.DTOs;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class MemberService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
public MemberService(
AppDbContext context,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor)
{
_context = context;
_tenantProvider = tenantProvider;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<MemberListDto>> GetMembersAsync()
{
var members = await _context.Members.ToListAsync();
return members.Select(m => new MemberListDto(
m.Id,
m.DisplayName,
m.Email,
m.Role.ToString()
)).ToList();
}
public async Task<MemberDetailDto?> GetMemberByIdAsync(Guid id)
{
var member = await _context.Members.FindAsync(id);
if (member == null)
return null;
return new MemberDetailDto(
member.Id,
member.DisplayName,
member.Email,
member.Role.ToString(),
member.ClubId,
member.CreatedAt,
member.UpdatedAt
);
}
public async Task<MemberDetailDto?> GetCurrentMemberAsync()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
return null;
}
var tenantId = _tenantProvider.GetTenantId();
var member = await _context.Members
.FirstOrDefaultAsync(m => m.ExternalUserId == userIdClaim && m.TenantId == tenantId);
if (member == null)
return null;
return new MemberDetailDto(
member.Id,
member.DisplayName,
member.Email,
member.Role.ToString(),
member.ClubId,
member.CreatedAt,
member.UpdatedAt
);
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using WorkClub.Application.Interfaces;
using WorkClub.Domain.Entities;
using WorkClub.Domain.Enums;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class MemberSyncService
{
private readonly AppDbContext _context;
private readonly ITenantProvider _tenantProvider;
public MemberSyncService(AppDbContext context, ITenantProvider tenantProvider)
{
_context = context;
_tenantProvider = tenantProvider;
}
public async Task EnsureMemberExistsAsync(HttpContext httpContext)
{
if (httpContext?.User?.Identity?.IsAuthenticated != true)
{
return;
}
var externalUserId = httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(externalUserId))
{
return;
}
var tenantId = _tenantProvider.GetTenantId();
var existingMember = await _context.Members
.FirstOrDefaultAsync(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId);
if (existingMember != null)
{
return;
}
var email = httpContext.User.FindFirst("email")?.Value ?? httpContext.User.FindFirst("preferred_username")?.Value ?? "unknown@example.com";
var name = httpContext.User.FindFirst("name")?.Value ?? email.Split('@')[0];
var roleClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "Member";
var clubRole = roleClaim.ToLowerInvariant() switch
{
"admin" => ClubRole.Admin,
"manager" => ClubRole.Manager,
"member" => ClubRole.Member,
"viewer" => ClubRole.Viewer,
_ => ClubRole.Member
};
var club = await _context.Clubs.FirstOrDefaultAsync(c => c.TenantId == tenantId);
if (club == null)
{
return;
}
var newMember = new Member
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ExternalUserId = externalUserId,
DisplayName = name,
Email = email,
Role = clubRole,
ClubId = club.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_context.Members.Add(newMember);
await _context.SaveChangesAsync();
}
}