feat(web): add private club showcases with membership flow (v3.7.0)
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:
2026-06-03 11:09:22 +03:00
parent 992f71c0e4
commit 6cb2fbe610
27 changed files with 1602 additions and 109 deletions
+329 -32
View File
@@ -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 });
}
}