6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions exclusively to a club's private showcase, gated behind a member application and approval flow. Adds a 4-state publication_mode (None/Catalog/ClubOnly/Both) replacing the binary is_public, plus a club_memberships table with Pending/Active/Rejected/Left lifecycle and partial unique index ensuring a single Active row per (group, player). Highlights - V030 migration: club_memberships, publication_mode, drop is_public, recreate partial indexes, portfolio_games gains publication_mode. - PublicationMode enum + extensions in GmRelay.Shared. - ISessionStore gains 12 membership/showcase methods; AuthorizedMembershipService owns the membership flow with GM-only approve/reject authorization. - PublicClub / PublicMasterProfile / PublicSession: member- aware queries (ClubOnly visible only to Active members). - New pages: MyClubMemberships (/profile/memberships) and ClubApplications (/group/{id}/applications). - GroupDetails and EditSession switch from a bool toggle to a 4-state publication_mode selector. - NavMenu adds Moji kluby, PublicLayout adds Kluby. Tests: 4 new test files (PublicationMode, ClubMemberships, AuthorizedMembershipService, ClubShowcaseSource) + updates to PublicClubPages, AuthorizedSessionService/Portfolio service FakeSessionStore, CampaignTemplatesNavigation. 493 tests pass. Bump version 3.6.0 -> 3.7.0 across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
5.0 KiB
C#
125 lines
5.0 KiB
C#
using System.Security.Claims;
|
|
using GmRelay.Shared.Domain;
|
|
|
|
namespace GmRelay.Web.Services;
|
|
|
|
public sealed class AuthorizedMembershipService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
|
|
{
|
|
private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
|
|
{
|
|
var user = httpContextAccessor.HttpContext?.User;
|
|
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
|
return null;
|
|
|
|
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
|
|
return (platform, externalUserId, name);
|
|
}
|
|
|
|
public async Task<Guid> ApplyForCurrentUserAsync(Guid groupId, string? message)
|
|
{
|
|
var identity = GetCurrentIdentity();
|
|
if (identity is null)
|
|
throw new InvalidOperationException("User is not authenticated.");
|
|
|
|
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
|
if (playerId is null)
|
|
{
|
|
throw new InvalidOperationException("Player record not found for current user.");
|
|
}
|
|
|
|
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
|
|
if (normalizedMessage?.Length > 1000)
|
|
{
|
|
throw new InvalidOperationException("Сообщение заявки должно быть не длиннее 1000 символов.");
|
|
}
|
|
|
|
return await sessionStore.ApplyForMembershipAsync(groupId, playerId.Value, normalizedMessage);
|
|
}
|
|
|
|
public async Task<List<WebMembership>> GetMineAsync()
|
|
{
|
|
var identity = GetCurrentIdentity();
|
|
if (identity is null)
|
|
return [];
|
|
|
|
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
|
if (playerId is null)
|
|
return [];
|
|
|
|
return await sessionStore.GetMembershipsForPlayerAsync(playerId.Value);
|
|
}
|
|
|
|
public async Task LeaveClubForCurrentUserAsync(Guid membershipId)
|
|
{
|
|
var identity = GetCurrentIdentity();
|
|
if (identity is null)
|
|
throw new InvalidOperationException("User is not authenticated.");
|
|
|
|
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
|
if (playerId is null)
|
|
throw new InvalidOperationException("Player record not found for current user.");
|
|
|
|
await sessionStore.LeaveClubMembershipAsync(membershipId, playerId.Value);
|
|
}
|
|
|
|
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
|
{
|
|
var identity = GetCurrentIdentity();
|
|
if (identity is null)
|
|
throw new InvalidOperationException("User is not authenticated.");
|
|
|
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
|
{
|
|
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
|
}
|
|
|
|
return await sessionStore.GetPendingApplicationsAsync(groupId);
|
|
}
|
|
|
|
public async Task<int> GetPendingApplicationsCountForCurrentGmAsync(Guid groupId)
|
|
{
|
|
var identity = GetCurrentIdentity();
|
|
if (identity is null)
|
|
return 0;
|
|
|
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
|
return 0;
|
|
|
|
return await sessionStore.GetPendingApplicationsCountAsync(groupId);
|
|
}
|
|
|
|
public async Task ApproveForCurrentGmAsync(Guid membershipId)
|
|
{
|
|
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
|
|
await sessionStore.ApproveMembershipAsync(membershipId, approverPlayerId);
|
|
}
|
|
|
|
public async Task RejectForCurrentGmAsync(Guid membershipId)
|
|
{
|
|
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
|
|
await sessionStore.RejectMembershipAsync(membershipId, approverPlayerId);
|
|
}
|
|
|
|
private async Task<(Guid ApproverPlayerId, Guid GroupId)> ResolveMembershipContextForGmAsync(Guid membershipId)
|
|
{
|
|
var identity = GetCurrentIdentity();
|
|
if (identity is null)
|
|
throw new InvalidOperationException("User is not authenticated.");
|
|
|
|
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
|
if (playerId is null)
|
|
throw new InvalidOperationException("Player record not found for current user.");
|
|
|
|
var groupId = await sessionStore.GetGroupIdForMembershipAsync(membershipId);
|
|
if (groupId is null)
|
|
throw new InvalidOperationException($"Membership {membershipId} not found.");
|
|
|
|
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
|
{
|
|
throw new SessionAccessDeniedException(groupId.Value, identity.Value.ExternalUserId);
|
|
}
|
|
|
|
return (playerId.Value, groupId.Value);
|
|
}
|
|
}
|