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:
37
backend/WorkClub.Api/Endpoints/Clubs/ClubEndpoints.cs
Normal file
37
backend/WorkClub.Api/Endpoints/Clubs/ClubEndpoints.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using WorkClub.Api.Services;
|
||||
using WorkClub.Application.Clubs.DTOs;
|
||||
|
||||
namespace WorkClub.Api.Endpoints.Clubs;
|
||||
|
||||
public static class ClubEndpoints
|
||||
{
|
||||
public static void MapClubEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/clubs");
|
||||
|
||||
group.MapGet("/me", GetMyClubs)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetMyClubs");
|
||||
|
||||
group.MapGet("/current", GetCurrentClub)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetCurrentClub");
|
||||
}
|
||||
|
||||
private static async Task<Ok<List<ClubListDto>>> GetMyClubs(ClubService clubService)
|
||||
{
|
||||
var result = await clubService.GetMyClubsAsync();
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ClubDetailDto>, NotFound>> GetCurrentClub(ClubService clubService)
|
||||
{
|
||||
var result = await clubService.GetCurrentClubAsync();
|
||||
|
||||
if (result == null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
}
|
||||
53
backend/WorkClub.Api/Endpoints/Members/MemberEndpoints.cs
Normal file
53
backend/WorkClub.Api/Endpoints/Members/MemberEndpoints.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using WorkClub.Api.Services;
|
||||
using WorkClub.Application.Members.DTOs;
|
||||
|
||||
namespace WorkClub.Api.Endpoints.Members;
|
||||
|
||||
public static class MemberEndpoints
|
||||
{
|
||||
public static void MapMemberEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/members");
|
||||
|
||||
group.MapGet("", GetMembers)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetMembers");
|
||||
|
||||
group.MapGet("{id:guid}", GetMemberById)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetMemberById");
|
||||
|
||||
group.MapGet("/me", GetCurrentMember)
|
||||
.RequireAuthorization("RequireMember")
|
||||
.WithName("GetCurrentMember");
|
||||
}
|
||||
|
||||
private static async Task<Ok<List<MemberListDto>>> GetMembers(MemberService memberService)
|
||||
{
|
||||
var result = await memberService.GetMembersAsync();
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<MemberDetailDto>, NotFound>> GetMemberById(
|
||||
Guid id,
|
||||
MemberService memberService)
|
||||
{
|
||||
var result = await memberService.GetMemberByIdAsync(id);
|
||||
|
||||
if (result == null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<MemberDetailDto>, NotFound>> GetCurrentMember(MemberService memberService)
|
||||
{
|
||||
var result = await memberService.GetCurrentMemberAsync();
|
||||
|
||||
if (result == null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
}
|
||||
26
backend/WorkClub.Api/Middleware/MemberSyncMiddleware.cs
Normal file
26
backend/WorkClub.Api/Middleware/MemberSyncMiddleware.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using WorkClub.Api.Services;
|
||||
|
||||
namespace WorkClub.Api.Middleware;
|
||||
|
||||
public class MemberSyncMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public MemberSyncMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, MemberSyncService memberSyncService)
|
||||
{
|
||||
try
|
||||
{
|
||||
await memberSyncService.EnsureMemberExistsAsync(context);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WorkClub.Api.Auth;
|
||||
using WorkClub.Api.Endpoints.Clubs;
|
||||
using WorkClub.Api.Endpoints.Members;
|
||||
using WorkClub.Api.Endpoints.Shifts;
|
||||
using WorkClub.Api.Endpoints.Tasks;
|
||||
using WorkClub.Api.Middleware;
|
||||
@@ -30,6 +32,9 @@ builder.Services.AddScoped<ITenantProvider, TenantProvider>();
|
||||
builder.Services.AddScoped<SeedDataService>();
|
||||
builder.Services.AddScoped<TaskService>();
|
||||
builder.Services.AddScoped<ShiftService>();
|
||||
builder.Services.AddScoped<ClubService>();
|
||||
builder.Services.AddScoped<MemberService>();
|
||||
builder.Services.AddScoped<MemberSyncService>();
|
||||
|
||||
builder.Services.AddSingleton<TenantDbConnectionInterceptor>();
|
||||
builder.Services.AddSingleton<SaveChangesTenantInterceptor>();
|
||||
@@ -91,6 +96,7 @@ app.UseAuthentication();
|
||||
app.UseMultiTenant();
|
||||
app.UseMiddleware<TenantValidationMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<MemberSyncMiddleware>();
|
||||
|
||||
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
@@ -124,6 +130,8 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
||||
|
||||
app.MapTaskEndpoints();
|
||||
app.MapShiftEndpoints();
|
||||
app.MapClubEndpoints();
|
||||
app.MapMemberEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
79
backend/WorkClub.Api/Services/ClubService.cs
Normal file
79
backend/WorkClub.Api/Services/ClubService.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
80
backend/WorkClub.Api/Services/MemberService.cs
Normal file
80
backend/WorkClub.Api/Services/MemberService.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
78
backend/WorkClub.Api/Services/MemberSyncService.cs
Normal file
78
backend/WorkClub.Api/Services/MemberSyncService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user