feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
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>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
normalizedBio);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
await sessionStore.SetSessionPublicationModeAsync(sessionId, session.GroupId, mode);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeForCurrentUserAsync(Guid batchId, PublicationMode mode)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
await sessionStore.SetBatchPublicationModeAsync(batchId, batch.GroupId, mode);
|
||||
}
|
||||
|
||||
public async Task<bool> IsActiveClubMemberForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return false;
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
return false;
|
||||
|
||||
return await sessionStore.IsActiveClubMemberAsync(groupId, playerId.Value);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
|
||||
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue,
|
||||
bool IsMembersOnly = false,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
public sealed record WebMembership(
|
||||
Guid MembershipId,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Status,
|
||||
string Role,
|
||||
string? Message,
|
||||
DateTime AppliedAt,
|
||||
DateTime? DecidedAt,
|
||||
string? DecidedByDisplayName);
|
||||
|
||||
public sealed record WebPendingApplication(
|
||||
Guid MembershipId,
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
string Platform,
|
||||
string? ExternalUsername,
|
||||
string? Message,
|
||||
DateTime AppliedAt);
|
||||
|
||||
public sealed record WebClubShowcaseSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode,
|
||||
bool IsMembersOnly,
|
||||
string? Description,
|
||||
bool AllowDirectRegistration);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
@@ -79,12 +120,14 @@ public interface ISessionStore
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
|
||||
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
|
||||
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
@@ -110,7 +153,7 @@ public interface ISessionStore
|
||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
|
||||
// --- Identity linking (issue #35) ---
|
||||
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
||||
@@ -123,6 +166,17 @@ public interface ISessionStore
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
|
||||
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
|
||||
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
|
||||
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
|
||||
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
|
||||
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
|
||||
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
|
||||
}
|
||||
|
||||
public sealed record LinkedIdentity(
|
||||
|
||||
@@ -69,7 +69,19 @@ public sealed record WebSession(
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||
int? ThreadId = null,
|
||||
bool IsPublic = false);
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue)
|
||||
{
|
||||
public bool IsPublic
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
|
||||
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
|
||||
}
|
||||
|
||||
public sealed record WebParticipant(
|
||||
Guid Id,
|
||||
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string? MasterProfileSlug,
|
||||
string? MasterDisplayName);
|
||||
string? MasterDisplayName,
|
||||
string PublicationMode = "None",
|
||||
bool IsMembersOnly = false);
|
||||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||||
|
||||
public sealed class SessionService(
|
||||
@@ -233,7 +247,7 @@ public sealed class SessionService(
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
@@ -266,18 +280,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -285,18 +299,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -304,7 +318,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
@@ -345,11 +359,11 @@ public sealed class SessionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
@@ -364,6 +378,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -404,9 +420,21 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
""",
|
||||
new
|
||||
{
|
||||
@@ -414,7 +442,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +508,7 @@ public sealed class SessionService(
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
@@ -518,7 +547,10 @@ public sealed class SessionService(
|
||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
r.Description,
|
||||
PublicationMode: "Catalog",
|
||||
IsMembersOnly: false,
|
||||
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||
@@ -583,7 +615,7 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
@@ -603,7 +635,10 @@ public sealed class SessionService(
|
||||
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
||||
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
|
||||
row.Description,
|
||||
PublicationMode: "Catalog",
|
||||
IsMembersOnly: false,
|
||||
row.MasterProfileSlug, row.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||
@@ -617,7 +652,7 @@ public sealed class SessionService(
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
@@ -868,7 +903,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -907,7 +942,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -967,7 +1002,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -1054,7 +1089,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1181,7 +1216,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1951,7 +1986,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||||
@@ -1971,7 +2006,7 @@ public sealed class SessionService(
|
||||
return null;
|
||||
|
||||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
|
||||
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||||
}
|
||||
|
||||
@@ -2004,7 +2039,8 @@ public sealed class SessionService(
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid playerId)
|
||||
Guid playerId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2018,6 +2054,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2051,9 +2089,21 @@ public sealed class SessionService(
|
||||
) waitlist_counts ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2061,13 +2111,15 @@ public sealed class SessionService(
|
||||
PlayerId = playerId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
Guid groupId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2081,6 +2133,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2121,9 +2175,21 @@ public sealed class SessionService(
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2132,7 +2198,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
@@ -2432,4 +2499,234 @@ public sealed class SessionService(
|
||||
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
|
||||
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var count = await conn.ExecuteScalarAsync<long>(
|
||||
"""
|
||||
SELECT COUNT(*) FROM club_memberships
|
||||
WHERE group_id = @GroupId
|
||||
AND player_id = @PlayerId
|
||||
AND status = 'Active'
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
|
||||
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebClubShowcaseSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.system AS System,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.format AS Format,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.cover_image_url AS CoverImageUrl,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
s.description AS Description,
|
||||
s.allow_direct_registration AS AllowDirectRegistration
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at ASC
|
||||
OFFSET @Offset LIMIT @PageSize
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId,
|
||||
Offset = page * pageSize,
|
||||
PageSize = pageSize
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND status = 'Pending'
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebPendingApplication>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.platform AS Platform,
|
||||
p.external_username AS ExternalUsername,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt
|
||||
FROM club_memberships cm
|
||||
JOIN players p ON p.id = cm.player_id
|
||||
WHERE cm.group_id = @GroupId
|
||||
AND cm.status = 'Pending'
|
||||
ORDER BY cm.applied_at ASC
|
||||
""",
|
||||
new { GroupId = groupId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebMembership>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
cm.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
cm.status AS Status,
|
||||
cm.role AS Role,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt,
|
||||
cm.decided_at AS DecidedAt,
|
||||
decider.display_name AS DecidedByDisplayName
|
||||
FROM club_memberships cm
|
||||
JOIN game_groups g ON g.id = cm.group_id
|
||||
LEFT JOIN players decider ON decider.id = cm.decided_by
|
||||
WHERE cm.player_id = @PlayerId
|
||||
ORDER BY cm.applied_at DESC
|
||||
""",
|
||||
new { PlayerId = playerId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var existing = await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
if (existing > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Active or pending application already exists for this player.");
|
||||
}
|
||||
|
||||
return await conn.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO club_memberships (group_id, player_id, status, message)
|
||||
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
|
||||
RETURNING id
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId, Message = message });
|
||||
}
|
||||
|
||||
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Left', decided_at = now()
|
||||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
|
||||
""",
|
||||
new { MembershipId = membershipId, PlayerId = playerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Active membership {membershipId} not found for player.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT group_id FROM club_memberships WHERE id = @MembershipId
|
||||
""",
|
||||
new { MembershipId = membershipId });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user