Files
GmRelayBot/src/GmRelay.Web/Services/AuthorizedMembershipService.cs
T
Toutsu 6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
feat(web): add private club showcases with membership flow (v3.7.0)
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>
2026-06-03 11:09:22 +03:00

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);
}
}