diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7c47119..bfd27d2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.6.0 + VERSION: 3.7.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 1467487..47228d8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.6.0 + 3.7.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index c4fda7c..5a143ec 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.0 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.6.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql b/src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql new file mode 100644 index 0000000..338be73 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql @@ -0,0 +1,66 @@ +-- V030: Private club showcases. Adds club_memberships (member access control) +-- and replaces sessions.is_public with a 4-state publication_mode enum. +-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'. +-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows). + +-- 1. club_memberships +CREATE TABLE club_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE, + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'Pending' + CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')), + role VARCHAR(20) NOT NULL DEFAULT 'Member' + CHECK (role IN ('Member')), + message TEXT, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + decided_at TIMESTAMPTZ, + decided_by UUID REFERENCES players(id) ON DELETE SET NULL +); + +-- Only one Active row per (group, player). +-- Re-application after Rejected/Left creates a new row. +CREATE UNIQUE INDEX ux_club_memberships_one_active + ON club_memberships (group_id, player_id) + WHERE status = 'Active'; + +CREATE INDEX ix_club_memberships_group_status + ON club_memberships (group_id, status); + +CREATE INDEX ix_club_memberships_player_status + ON club_memberships (player_id, status); + +-- 2. sessions.publication_mode (replaces is_public) +ALTER TABLE sessions + ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None'; + +-- Backfill before constraint so existing data maps cleanly. +UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true; +UPDATE sessions SET publication_mode = 'None' WHERE is_public = false; + +ALTER TABLE sessions + ADD CONSTRAINT ck_sessions_publication_mode + CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both')); + +ALTER TABLE sessions DROP COLUMN is_public; + +DROP INDEX IF EXISTS ix_sessions_public_schedule; +DROP INDEX IF EXISTS ix_sessions_showcase; + +CREATE INDEX ix_sessions_public_schedule + ON sessions (group_id, scheduled_at) + WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled'; + +CREATE INDEX ix_sessions_showcase + ON sessions (scheduled_at, system, is_one_shot, format) + WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled'; + +-- 3. portfolio_games.publication_mode +-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors. +ALTER TABLE portfolio_games + ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both' + CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both')); + +CREATE INDEX ix_portfolio_games_showcase + ON portfolio_games (created_at DESC) + WHERE publication_mode IN ('Catalog', 'Both'); diff --git a/src/GmRelay.Shared/Domain/PublicationMode.cs b/src/GmRelay.Shared/Domain/PublicationMode.cs new file mode 100644 index 0000000..5dcb004 --- /dev/null +++ b/src/GmRelay.Shared/Domain/PublicationMode.cs @@ -0,0 +1,44 @@ +namespace GmRelay.Shared.Domain; + +public enum PublicationMode +{ + None, + Catalog, + ClubOnly, + Both +} + +public static class PublicationModeExtensions +{ + public const string NoneValue = nameof(PublicationMode.None); + public const string CatalogValue = nameof(PublicationMode.Catalog); + public const string ClubOnlyValue = nameof(PublicationMode.ClubOnly); + public const string BothValue = nameof(PublicationMode.Both); + + public static bool IsVisibleInCatalog(this PublicationMode mode) => + mode is PublicationMode.Catalog or PublicationMode.Both; + + public static bool IsVisibleToClubMembers(this PublicationMode mode) => + mode is PublicationMode.ClubOnly or PublicationMode.Both; + + public static string ToDatabaseValue(this PublicationMode mode) => + mode switch + { + PublicationMode.None => NoneValue, + PublicationMode.Catalog => CatalogValue, + PublicationMode.ClubOnly => ClubOnlyValue, + PublicationMode.Both => BothValue, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown publication mode.") + }; + + public static PublicationMode FromDatabaseValue(string? value) => + value switch + { + null or "" => PublicationMode.None, + NoneValue => PublicationMode.None, + CatalogValue => PublicationMode.Catalog, + ClubOnlyValue => PublicationMode.ClubOnly, + BothValue => PublicationMode.Both, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown publication mode.") + }; +} diff --git a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs index 2844d82..bd3e4f3 100644 --- a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs +++ b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs @@ -18,5 +18,7 @@ public sealed record ShowcaseSessionDto( int WaitlistedPlayerCount, bool AllowDirectRegistration, string? Description, + string PublicationMode = "None", + bool IsMembersOnly = false, string? MasterProfileSlug = null, string? MasterDisplayName = null); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 6f1ba21..703e651 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -41,6 +41,15 @@ Профиль + + + + + + + + Мои клубы + diff --git a/src/GmRelay.Web/Components/Layout/PublicLayout.razor b/src/GmRelay.Web/Components/Layout/PublicLayout.razor index 76606bf..63f68b2 100644 --- a/src/GmRelay.Web/Components/Layout/PublicLayout.razor +++ b/src/GmRelay.Web/Components/Layout/PublicLayout.razor @@ -6,7 +6,10 @@ GM-Relay GM-Relay - Войти +
diff --git a/src/GmRelay.Web/Components/Pages/ClubApplications.razor b/src/GmRelay.Web/Components/Pages/ClubApplications.razor new file mode 100644 index 0000000..e34ae98 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/ClubApplications.razor @@ -0,0 +1,151 @@ +@page "/group/{GroupId:guid}/applications" +@using GmRelay.Web.Services +@using GmRelay.Shared.Domain +@using Microsoft.AspNetCore.Authorization +@attribute [Authorize] +@inject AuthorizedMembershipService MembershipService +@inject AuthorizedSessionService SessionService +@inject NavigationManager Navigation +@inject IHttpContextAccessor HttpContextAccessor +@using System.Security.Claims + +Заявки участников — GM-Relay + +
+ + + + + @if (accessDenied) + { +
+

Нет доступа

+

Только owner или co-GM группы могут просматривать заявки.

+
+ } + else if (applications is null) + { +
+
+
+
+ } + else if (applications.Count == 0) + { +
+

Новых заявок нет

+

Когда игроки подадут заявку на участие в клубе, она появится здесь.

+
+ } + else + { +
    + @foreach (var app in applications) + { +
  • +
    + @app.DisplayName + @app.Platform + @if (!string.IsNullOrWhiteSpace(app.ExternalUsername)) + { + @app.ExternalUsername + } + @app.AppliedAt.ToString("dd.MM.yyyy HH:mm") + @if (!string.IsNullOrWhiteSpace(app.Message)) + { +

    «@app.Message»

    + } +
    +
    + + +
    +
  • + } +
+ } + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
⚠️ @errorMessage
+ } +
+ +@code { + [Parameter] public Guid GroupId { get; set; } + + private List? applications; + private bool accessDenied; + private string? errorMessage; + private Guid? busyMembershipId; + + protected override async Task OnParametersSetAsync() + { + accessDenied = false; + try + { + applications = await MembershipService.GetPendingApplicationsAsync(GroupId); + } + catch (SessionAccessDeniedException) + { + accessDenied = true; + } + } + + private async Task Approve(Guid membershipId) + { + errorMessage = null; + busyMembershipId = membershipId; + try + { + await MembershipService.ApproveForCurrentGmAsync(membershipId); + applications = await MembershipService.GetPendingApplicationsAsync(GroupId); + } + catch (SessionAccessDeniedException) + { + accessDenied = true; + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + finally + { + busyMembershipId = null; + } + } + + private async Task Reject(Guid membershipId) + { + errorMessage = null; + busyMembershipId = membershipId; + try + { + await MembershipService.RejectForCurrentGmAsync(membershipId); + applications = await MembershipService.GetPendingApplicationsAsync(GroupId); + } + catch (SessionAccessDeniedException) + { + accessDenied = true; + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + finally + { + busyMembershipId = null; + } + } +} diff --git a/src/GmRelay.Web/Components/Pages/EditSession.razor b/src/GmRelay.Web/Components/Pages/EditSession.razor index 2ac809d..e290acc 100644 --- a/src/GmRelay.Web/Components/Pages/EditSession.razor +++ b/src/GmRelay.Web/Components/Pages/EditSession.razor @@ -57,6 +57,16 @@
Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.
+
+ + + + + + + +
+
} + @if (pendingApplicationsCount > 0) + { + + 📨 Заявки участников (@pendingApplicationsCount) + Рассмотреть заявки на участие в клубе + + } + @if (!string.IsNullOrEmpty(errorMessage)) {
@@ -313,11 +322,12 @@ 0 ? "status-warning" : "status-neutral")"> @FormatBatchPublication(batch) - +
@@ -369,11 +379,12 @@
@FormatPublicationStatus(session) - + @if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true) { Публичная ссылка @@ -466,11 +477,12 @@
- + @if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true) { Публичная ссылка @@ -540,6 +552,7 @@ private IReadOnlyList? portfolioGames; private List batchModels = []; private List campaignTemplateModels = []; + private int pendingApplicationsCount; private Guid? promotingSessionId; private Guid? processingBatchId; private Guid? processingTemplateId; @@ -605,6 +618,8 @@ portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId); + pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId); + RebuildBatchModels(); RebuildCampaignTemplateModels(); RebuildPublicSettingsModel(); @@ -664,7 +679,7 @@ } } - private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic) + private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode) { errorMessage = null; successMessage = null; @@ -672,10 +687,14 @@ try { - await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic); - successMessage = isPublic - ? "Batch опубликован в публичном расписании." - : "Batch скрыт из публичного расписания."; + await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode); + successMessage = mode switch + { + PublicationMode.Catalog => "Batch опубликован в общем каталоге.", + PublicationMode.ClubOnly => "Batch доступен только участникам клуба.", + PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.", + _ => "Batch скрыт из публичного расписания." + }; await LoadSessions(); } catch (SessionAccessDeniedException) @@ -692,7 +711,7 @@ } } - private async Task SetSessionPublic(Guid sessionId, bool isPublic) + private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode) { errorMessage = null; successMessage = null; @@ -700,10 +719,14 @@ try { - await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic); - successMessage = isPublic - ? "Сессия опубликована в публичном расписании." - : "Сессия скрыта из публичного расписания."; + await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode); + successMessage = mode switch + { + PublicationMode.Catalog => "Сессия опубликована в общем каталоге.", + PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.", + PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.", + _ => "Сессия скрыта из публичного расписания." + }; await LoadSessions(); } catch (SessionAccessDeniedException) @@ -1073,7 +1096,13 @@ IntervalDays = InferIntervalDays(orderedSessions), SessionCount = orderedSessions.Count, PublicSessionCount = orderedSessions.Count(session => session.IsPublic), - AllSessionsPublic = orderedSessions.All(session => session.IsPublic) + AllSessionsPublic = orderedSessions.All(session => session.IsPublic), + PublicationMode = orderedSessions + .Select(s => PublicationModeExtensions.FromDatabaseValue(s.PublicationMode)) + .GroupBy(m => m) + .OrderByDescending(g => g.Count()) + .First() + .Key }; }) .OrderBy(batch => batch.FirstScheduledAtLocal) @@ -1220,6 +1249,9 @@ : seats; } + private static PublicationMode ParseMode(object? value) => + Enum.TryParse(value?.ToString(), out var mode) ? mode : PublicationMode.None; + private static string FormatBatchSummary(BatchBulkEditModel batch) => $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; @@ -1272,6 +1304,7 @@ public int SessionCount { get; init; } public int PublicSessionCount { get; init; } public bool AllSessionsPublic { get; init; } + public PublicationMode PublicationMode { get; set; } = PublicationMode.None; public string CloneInterval { get; set; } = "week"; } diff --git a/src/GmRelay.Web/Components/Pages/MyClubMemberships.razor b/src/GmRelay.Web/Components/Pages/MyClubMemberships.razor new file mode 100644 index 0000000..b7c12fa --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/MyClubMemberships.razor @@ -0,0 +1,147 @@ +@page "/profile/memberships" +@using GmRelay.Web.Services +@using Microsoft.AspNetCore.Authorization +@attribute [Authorize] +@inject AuthorizedMembershipService MembershipService +@inject NavigationManager Navigation + +Мои клубы — GM-Relay + +
+ + + + + @if (memberships is null) + { +
+
+
+
+ } + else if (memberships.Count == 0) + { +
+

Вы пока не подавали заявок

+

Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.

+ К каталогу клубов +
+ } + else + { + @if (activeMemberships.Count > 0) + { +
+

Активные участия

+
    + @foreach (var membership in activeMemberships) + { +
  • +
    + + @membership.GroupName + + Участник +
    + +
  • + } +
+
+ } + + @if (pendingMemberships.Count > 0) + { +
+

Заявки на рассмотрении

+
    + @foreach (var membership in pendingMemberships) + { +
  • +
    + @membership.GroupName + Ожидает одобрения +
    + +
  • + } +
+
+ } + + @if (historyMemberships.Count > 0) + { +
+

История

+
    + @foreach (var membership in historyMemberships) + { +
  • +
    + @membership.GroupName + + @(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб") + + @if (membership.DecidedAt is not null) + { + @membership.DecidedAt.Value.ToString("dd.MM.yyyy") + } +
    +
  • + } +
+
+ } + } + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
⚠️ @errorMessage
+ } +
+ +@code { + private List? memberships; + private List activeMemberships = []; + private List pendingMemberships = []; + private List historyMemberships = []; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + errorMessage = null; + memberships = await MembershipService.GetMineAsync(); + activeMemberships = memberships.Where(m => m.Status == "Active").ToList(); + pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList(); + historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList(); + } + + private async Task Leave(Guid membershipId) + { + errorMessage = null; + try + { + await MembershipService.LeaveClubForCurrentUserAsync(membershipId); + await LoadAsync(); + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + } +} diff --git a/src/GmRelay.Web/Components/Pages/PublicClub.razor b/src/GmRelay.Web/Components/Pages/PublicClub.razor index e8c6b25..0e31dde 100644 --- a/src/GmRelay.Web/Components/Pages/PublicClub.razor +++ b/src/GmRelay.Web/Components/Pages/PublicClub.razor @@ -3,6 +3,9 @@ @inject ISessionStore SessionStore @inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@inject IHttpContextAccessor HttpContextAccessor +@inject AuthorizedMembershipService MembershipService +@using System.Security.Claims @using GmRelay.Web.Components.Portfolio @using GmRelay.Web.Services.Portfolio @@ -61,22 +64,79 @@ else if (club is not null) } else { -
- @foreach (var session in club.Sessions) - { -
-
- @TranslateStatus(session.Status) -

@session.Title

-
- @session.ScheduledAt.FormatMoscow() - @FormatSeats(session) + var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList(); + var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList(); + + @if (publicSessions.Count > 0) + { +
+ @foreach (var session in publicSessions) + { +
+
+ @TranslateStatus(session.Status) +

@session.Title

+
+ @session.ScheduledAt.FormatMoscow() + @FormatSeats(session) +
+ Открыть +
+ } +
+ } + + @if (membersOnlySessions.Count > 0) + { +
+

Игры для участников клуба

+ @if (viewerIsActiveMember) + { +
+ @foreach (var session in membersOnlySessions) + { +
+
+ Только для участников +

@session.Title

+
+ @session.ScheduledAt.FormatMoscow() + @FormatSeats(session) +
+
+ Открыть +
+ }
- Открыть -
- } -
+ } + else + { +

Эти сессии доступны только одобренным участникам клуба.

+ @if (viewerPlayerId is null) + { + Войти как участник + } + else + { +
+ Подать заявку + +
+ + +
+ @if (!string.IsNullOrEmpty(applicationError)) + { +

@applicationError

+ } + +
+
+ } + } + + } } @if (portfolioGames.Count > 0) @@ -95,6 +155,33 @@ else if (club is not null) private WebPublicClub? club; private IReadOnlyList portfolioGames = []; private bool loaded; + private Guid? viewerPlayerId; + private bool viewerIsActiveMember; + private string? applicationError; + private string? applicationMessage; + private bool isSubmittingApplication; + + private async Task TrySubmitApplicationAsync() + { + applicationError = null; + if (club is null) + return; + + try + { + isSubmittingApplication = true; + await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage); + applicationMessage = null; + } + catch (InvalidOperationException ex) + { + applicationError = ex.Message; + } + finally + { + isSubmittingApplication = false; + } + } private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay"; @@ -107,12 +194,41 @@ else if (club is not null) { loaded = false; var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + applicationError = null; + applicationMessage = null; + + // Resolve viewer identity (player id) for member-aware access. + var user = HttpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId)) + { + // We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly + // by reading both claims. Simpler: only resolve when both Platform and externalUserId are present. + var platform = user.FindFirst("Platform")?.Value; + viewerPlayerId = !string.IsNullOrWhiteSpace(platform) + ? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId) + : null; + } + else + { + viewerPlayerId = null; + } + club = trimmedSlug is null ? null - : await SessionStore.GetPublicClubBySlugAsync(trimmedSlug); + : await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId); portfolioGames = trimmedSlug is null ? [] : await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug); + + if (club is not null && viewerPlayerId is not null) + { + viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value); + } + else + { + viewerIsActiveMember = false; + } + loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor index fd40887..57940c4 100644 --- a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor +++ b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor @@ -3,6 +3,7 @@ @inject ISessionStore SessionStore @inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@inject IHttpContextAccessor HttpContextAccessor @using GmRelay.Web.Components.Portfolio @using GmRelay.Web.Services.Portfolio @@ -115,9 +116,20 @@ else if (profile is not null) { loaded = false; var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + + Guid? viewerPlayerId = null; + var user = HttpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId)) + { + var platform = user.FindFirst("Platform")?.Value; + viewerPlayerId = !string.IsNullOrWhiteSpace(platform) + ? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId) + : null; + } + profile = trimmedSlug is null ? null - : await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug); + : await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId); portfolioGames = trimmedSlug is null ? [] : await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug); diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 66318ea..6dff33d 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/src/GmRelay.Web/Services/AuthorizedMembershipService.cs b/src/GmRelay.Web/Services/AuthorizedMembershipService.cs new file mode 100644 index 0000000..1558d45 --- /dev/null +++ b/src/GmRelay.Web/Services/AuthorizedMembershipService.cs @@ -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 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> 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> 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 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); + } +} diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index f6d3e8a..3395a42 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -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 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 GetSessionForCurrentUserAsync(Guid sessionId) diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index ec97f42..ebd434a 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -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 GetGroupAsync(Guid groupId); Task 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 GetPublicClubBySlugAsync(string slug); - Task GetPublicSessionAsync(Guid sessionId); + Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode); + Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode); + Task GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId); + Task GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId); Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId); Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId); + Task IsActiveClubMemberAsync(Guid groupId, Guid playerId); + Task GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId); Task> GetGroupManagersAsync(Guid groupId); Task> GetUpcomingSessionsAsync(Guid groupId); Task GetSessionAsync(Guid sessionId); @@ -110,7 +153,7 @@ public interface ISessionStore Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl); Task GetMasterProfileSettingsAsync(string platform, string externalUserId); Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio); - Task GetPublicMasterProfileBySlugAsync(string slug); + Task GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId); // --- Identity linking (issue #35) --- Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId); @@ -123,6 +166,17 @@ public interface ISessionStore Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize); Task GetShowcaseSessionAsync(Guid sessionId); Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName); + + // --- Private club showcases / memberships (issue #110) --- + Task> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize); + Task GetPendingApplicationsCountAsync(Guid groupId); + Task> GetPendingApplicationsAsync(Guid groupId); + Task> GetMembershipsForPlayerAsync(Guid playerId); + Task 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 GetGroupIdForMembershipAsync(Guid membershipId); } public sealed record LinkedIdentity( diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 865acdd..79e5f29 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -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 GetPublicClubBySlugAsync(string slug) + public async Task GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId) { await using var conn = await dataSource.OpenConnectionAsync(); var group = await conn.QuerySingleOrDefaultAsync( @@ -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 GetPublicSessionAsync(Guid sessionId) + public async Task GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( @@ -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 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 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 GetPublicMasterProfileBySlugAsync(string slug) + public async Task GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId) { await using var conn = await dataSource.OpenConnectionAsync(); var profile = await conn.QuerySingleOrDefaultAsync( @@ -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> GetPublicSessionsForMasterAsync( NpgsqlConnection conn, - Guid playerId) + Guid playerId, + Guid? viewerPlayerId) { return (await conn.QueryAsync( """ @@ -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> GetPublicSessionsForGroupAsync( NpgsqlConnection conn, - Guid groupId) + Guid groupId, + Guid? viewerPlayerId) { return (await conn.QueryAsync( """ @@ -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,248 @@ 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 IsActiveClubMemberAsync(Guid groupId, Guid playerId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var count = await conn.ExecuteScalarAsync( + """ + 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 GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + } + + public async Task> GetClubShowcaseSessionsAsync( + Guid groupId, Guid? viewerPlayerId, int page, int pageSize) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + 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 GetPendingApplicationsCountAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*)::int FROM club_memberships + WHERE group_id = @GroupId AND status = 'Pending' + """, + new { GroupId = groupId }); + } + + public async Task> GetPendingApplicationsAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + 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> GetMembershipsForPlayerAsync(Guid playerId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + 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 ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var existing = await conn.ExecuteScalarAsync( + """ + 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( + """ + 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(); + // Active membership: withdraw by setting status = 'Left'. + 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) + { + return; + } + + // Pending application: cancel by setting status = 'Rejected' so the user can re-apply later. + var cancelled = await conn.ExecuteAsync( + """ + UPDATE club_memberships + SET status = 'Rejected', decided_at = now() + WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending' + """, + new { MembershipId = membershipId, PlayerId = playerId }); + if (cancelled == 0) + { + throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player."); + } + } + + public async Task GetGroupIdForMembershipAsync(Guid membershipId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + """ + SELECT group_id FROM club_memberships WHERE id = @MembershipId + """, + new { MembershipId = membershipId }); + } } diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedMembershipServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedMembershipServiceTests.cs new file mode 100644 index 0000000..79a3272 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedMembershipServiceTests.cs @@ -0,0 +1,62 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class AuthorizedMembershipServiceTests +{ + [Fact] + public async Task AuthorizedMembershipService_ShouldRequireAuthenticationForApply() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs"); + + Assert.Contains("ApplyForCurrentUserAsync", service, StringComparison.Ordinal); + Assert.Contains("User is not authenticated", service, StringComparison.Ordinal); + Assert.Contains("GetPlayerIdByPlatformIdentityAsync", service, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthorizedMembershipService_ShouldValidateMessageLength() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs"); + + Assert.Contains("1000", service, StringComparison.Ordinal); + Assert.Contains("н", service, StringComparison.Ordinal); // Russian message length + } + + [Fact] + public async Task AuthorizedMembershipService_ShouldRestrictGmActionsToGroupManagers() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs"); + + Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal); + Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal); + Assert.Contains("ResolveMembershipContextForGmAsync", service, StringComparison.Ordinal); + Assert.Contains("GetGroupIdForMembershipAsync", service, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthorizedMembershipService_ShouldExposePendingApplications() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs"); + + Assert.Contains("GetPendingApplicationsAsync", service, StringComparison.Ordinal); + Assert.Contains("GetPendingApplicationsCountForCurrentGmAsync", service, StringComparison.Ordinal); + Assert.Contains("GetMineAsync", service, StringComparison.Ordinal); + Assert.Contains("LeaveClubForCurrentUserAsync", service, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs index 2e43048..1fee0cd 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs @@ -794,10 +794,21 @@ public sealed class AuthorizedPortfolioServiceTests public Task GetGroupAsync(Guid groupId) => throw new NotImplementedException(); public Task GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException(); public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException(); - public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException(); - public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException(); - public Task GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException(); - public Task GetPublicSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException(); + public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException(); + public Task GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException(); + public Task GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException(); + public Task IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException(); + public Task GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException(); + public Task GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException(); + public Task> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException(); + public Task> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException(); + public Task ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException(); + public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException(); + public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException(); + public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException(); + public Task GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException(); public Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException(); public Task> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException(); public Task> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException(); @@ -824,7 +835,7 @@ public sealed class AuthorizedPortfolioServiceTests public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException(); public Task GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException(); public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException(); - public Task GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException(); + public Task GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException(); public Task> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException(); public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException(); public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException(); diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 744e297..0929068 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -839,9 +839,11 @@ public sealed class AuthorizedSessionServiceTests public Guid? LastPublicSessionId { get; private set; } public Guid? LastPublicSessionGroupId { get; private set; } public bool? LastSessionPublicValue { get; private set; } + public PublicationMode? LastSessionPublicationMode { get; private set; } public Guid? LastPublicBatchId { get; private set; } public Guid? LastPublicBatchGroupId { get; private set; } public bool? LastBatchPublicValue { get; private set; } + public PublicationMode? LastBatchPublicationMode { get; private set; } public bool RemovePlayerCalled { get; private set; } public Guid? LastRemovedPlayerSessionId { get; private set; } public Guid? LastRemovedPlayerGroupId { get; private set; } @@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests return Task.CompletedTask; } - public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) + public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) { SetSessionPublicCalled = true; LastPublicSessionId = sessionId; LastPublicSessionGroupId = groupId; - LastSessionPublicValue = isPublic; + LastSessionPublicationMode = mode; if (sessionsById.TryGetValue(sessionId, out var session)) { - sessionsById[sessionId] = session with { IsPublic = isPublic }; + sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() }; } return Task.CompletedTask; } - public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) + public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) { SetBatchPublicCalled = true; LastPublicBatchId = batchId; LastPublicBatchGroupId = groupId; - LastBatchPublicValue = isPublic; + LastBatchPublicationMode = mode; foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList()) { - sessionsById[session.Id] = session with { IsPublic = isPublic }; + sessionsById[session.Id] = session with { PublicationMode = mode.ToDatabaseValue() }; } return Task.CompletedTask; } - public Task GetPublicClubBySlugAsync(string slug) => + public Task GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => Task.FromResult(null); - public Task GetPublicSessionAsync(Guid sessionId) => + public Task GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => Task.FromResult(null); public Task IsGroupManagerAsync(Guid groupId, long telegramId) => Task.FromResult(IsManager(groupId, telegramId)); + public Task IsActiveClubMemberAsync(Guid groupId, Guid playerId) => + Task.FromResult(false); + + public Task GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => + Task.FromResult(null); + + public Task> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => + Task.FromResult>([]); + + public Task GetPendingApplicationsCountAsync(Guid groupId) => + Task.FromResult(0); + + public Task> GetPendingApplicationsAsync(Guid groupId) => + Task.FromResult(new List()); + + public Task> GetMembershipsForPlayerAsync(Guid playerId) => + Task.FromResult(new List()); + + public Task ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => + throw new NotImplementedException(); + + public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => + throw new NotImplementedException(); + + public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => + throw new NotImplementedException(); + + public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => + throw new NotImplementedException(); + + public Task GetGroupIdForMembershipAsync(Guid membershipId) => + Task.FromResult(null); + + public Task GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => + Task.FromResult(null); + public Task IsGroupOwnerAsync(Guid groupId, long telegramId) => Task.FromResult(IsOwner(groupId, telegramId)); diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs index d3e36c2..b3248b7 100644 --- a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests public async Task NavMenu_ShouldExposeCurrentProjectVersion() { var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); - Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal); + Assert.Contains("v3.7.0", navMenu, StringComparison.Ordinal); } [Fact] diff --git a/tests/GmRelay.Bot.Tests/Web/ClubMembershipsTests.cs b/tests/GmRelay.Bot.Tests/Web/ClubMembershipsTests.cs new file mode 100644 index 0000000..580e0ec --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/ClubMembershipsTests.cs @@ -0,0 +1,94 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class ClubMembershipsTests +{ + [Fact] + public async Task SessionStore_ShouldExposeMembershipMethods() + { + var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs"); + + Assert.Contains("ApplyForMembershipAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("ApproveMembershipAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("RejectMembershipAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("LeaveClubMembershipAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("GetPendingApplicationsAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("GetMembershipsForPlayerAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("IsActiveClubMemberAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("GetGroupIdForMembershipAsync", sessionStore, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionService_ShouldFilterPublicSessionsWithMemberAwareClause() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); + + // Member-aware: ClubOnly only visible to Active members + Assert.Contains("publication_mode = 'ClubOnly'", service, StringComparison.Ordinal); + Assert.Contains("club_memberships", service, StringComparison.Ordinal); + Assert.Contains("cm.status = 'Active'", service, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthorizedMembershipService_ShouldValidateCallerForGmActions() + { + var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs"); + + Assert.Contains("IsGroupManagerAsync", service, StringComparison.Ordinal); + Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal); + Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal); + Assert.Contains("SessionAccessDeniedException", service, StringComparison.Ordinal); + } + + [Fact] + public async Task MyClubMembershipsPage_ShouldRenderLeaveAndCancelButtons() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/MyClubMemberships.razor"); + + Assert.Contains("@page \"/profile/memberships\"", page, StringComparison.Ordinal); + Assert.Contains("[Authorize]", page, StringComparison.Ordinal); + Assert.Contains("Покинуть клуб", page, StringComparison.Ordinal); + Assert.Contains("Отозвать заявку", page, StringComparison.Ordinal); + Assert.Contains("Active", page, StringComparison.Ordinal); + Assert.Contains("Pending", page, StringComparison.Ordinal); + } + + [Fact] + public async Task ClubApplicationsPage_ShouldRenderApproveAndReject() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/ClubApplications.razor"); + + Assert.Contains("/applications", page, StringComparison.Ordinal); + Assert.Contains("[Authorize]", page, StringComparison.Ordinal); + Assert.Contains("Одобрить", page, StringComparison.Ordinal); + Assert.Contains("Отклонить", page, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPage_ShouldExposeApplicationCtaAndMembersOnlyBlock() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + Assert.Contains("viewerPlayerId", page, StringComparison.Ordinal); + Assert.Contains("Подать заявку", page, StringComparison.Ordinal); + Assert.Contains("Войти как участник", page, StringComparison.Ordinal); + Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal); + Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/ClubShowcaseSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/ClubShowcaseSourceTests.cs new file mode 100644 index 0000000..c5f7435 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/ClubShowcaseSourceTests.cs @@ -0,0 +1,104 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class ClubShowcaseSourceTests +{ + [Fact] + public async Task PublicClubPage_ShouldRenderMembersOnlyBlock() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal); + Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal); + Assert.Contains("members-only-section", page, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPage_ShouldRenderApplyAndLoginCtas() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + Assert.Contains("Подать заявку", page, StringComparison.Ordinal); + Assert.Contains("Войти как участник", page, StringComparison.Ordinal); + Assert.Contains("applicationMessage", page, StringComparison.Ordinal); + Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPage_ShouldHideMembersOnlyBlockForAnonymous() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + // Anonymous users must not see the members-only block content + Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal); + // Login CTA appears when viewerPlayerId is null + Assert.Contains("viewerPlayerId is null", page, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicLayout_ShouldExposeClubsLink() + { + var layout = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/PublicLayout.razor"); + + Assert.Contains("href=\"/showcase\"", layout, StringComparison.Ordinal); + Assert.Contains("Клубы", layout, StringComparison.Ordinal); + } + + [Fact] + public async Task NavMenu_ShouldExposeMyClubsLink() + { + var menu = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/NavMenu.razor"); + + Assert.Contains("href=\"profile/memberships\"", menu, StringComparison.Ordinal); + Assert.Contains("Мои клубы", menu, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupDetails_ShouldExposeApplicationsLink() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor"); + + Assert.Contains("/applications", page, StringComparison.Ordinal); + Assert.Contains("Заявки участников", page, StringComparison.Ordinal); + Assert.Contains("pendingApplicationsCount", page, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupDetails_ShouldUsePublicationModeSelectorNotBooleanToggle() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor"); + + Assert.DoesNotContain("SetSessionPublic(session.Id, !session.IsPublic)", page, StringComparison.Ordinal); + Assert.DoesNotContain("SetBatchPublic(batch, !batch.AllSessionsPublic)", page, StringComparison.Ordinal); + Assert.Contains("SetSessionPublicationMode", page, StringComparison.Ordinal); + Assert.Contains("SetBatchPublicationMode", page, StringComparison.Ordinal); + } + + [Fact] + public async Task EditSession_ShouldExposePublicationModeSelector() + { + var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/EditSession.razor"); + + Assert.Contains("PublicationMode", page, StringComparison.Ordinal); + Assert.Contains("Режим публикации", page, StringComparison.Ordinal); + Assert.Contains("Catalog", page, StringComparison.Ordinal); + Assert.Contains("ClubOnly", page, StringComparison.Ordinal); + Assert.Contains("Both", page, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PublicClubPagesTests.cs b/tests/GmRelay.Bot.Tests/Web/PublicClubPagesTests.cs index 97a0b7e..b29da91 100644 --- a/tests/GmRelay.Bot.Tests/Web/PublicClubPagesTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PublicClubPagesTests.cs @@ -14,6 +14,20 @@ public sealed class PublicClubPagesTests Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal); } + [Fact] + public async Task MigrationV030_ShouldAddClubMembershipsAndPublicationMode() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql"); + + Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal); + Assert.Contains("status", migration, StringComparison.Ordinal); + Assert.Contains("role", migration, StringComparison.Ordinal); + Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal); + Assert.Contains("publication_mode", migration, StringComparison.Ordinal); + Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal); + Assert.Contains("portfolio_games", migration, StringComparison.Ordinal); + } + [Fact] public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData() { @@ -40,10 +54,10 @@ public sealed class PublicClubPagesTests Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal); Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal); - Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal); - Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("SetSessionPublicationModeAsync", sessionStore, StringComparison.Ordinal); + Assert.Contains("SetBatchPublicationModeAsync", sessionStore, StringComparison.Ordinal); Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal); - Assert.Contains("s.is_public = true", service, StringComparison.Ordinal); + Assert.Contains("s.publication_mode IN ('Catalog', 'Both')", service, StringComparison.Ordinal); Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal); Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal); } @@ -55,8 +69,8 @@ public sealed class PublicClubPagesTests var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs"); Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal); - Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal); - Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal); + Assert.Contains("SetSessionPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal); + Assert.Contains("SetBatchPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal); Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal); Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal); Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal); diff --git a/tests/GmRelay.Bot.Tests/Web/PublicationModeTests.cs b/tests/GmRelay.Bot.Tests/Web/PublicationModeTests.cs new file mode 100644 index 0000000..a14c8dc --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PublicationModeTests.cs @@ -0,0 +1,79 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PublicationModeTests +{ + [Fact] + public void PublicationMode_ShouldHaveFourValues() + { + var values = Enum.GetValues(); + Assert.Equal(4, values.Length); + Assert.Contains(GmRelay.Shared.Domain.PublicationMode.None, values); + Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Catalog, values); + Assert.Contains(GmRelay.Shared.Domain.PublicationMode.ClubOnly, values); + Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Both, values); + } + + [Fact] + public async Task MigrationV030_ShouldAddClubMembershipsTable() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql"); + + Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal); + Assert.Contains("status", migration, StringComparison.Ordinal); + Assert.Contains("role", migration, StringComparison.Ordinal); + Assert.Contains("Pending", migration, StringComparison.Ordinal); + Assert.Contains("Active", migration, StringComparison.Ordinal); + Assert.Contains("Rejected", migration, StringComparison.Ordinal); + Assert.Contains("Left", migration, StringComparison.Ordinal); + Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal); + Assert.Contains("Member", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV030_ShouldReplaceIsPublicWithPublicationModeEnum() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql"); + + Assert.Contains("ADD COLUMN publication_mode", migration, StringComparison.Ordinal); + Assert.Contains("ck_sessions_publication_mode", migration, StringComparison.Ordinal); + Assert.Contains("'None', 'Catalog', 'ClubOnly', 'Both'", migration, StringComparison.Ordinal); + Assert.Contains("UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true", migration, StringComparison.Ordinal); + Assert.Contains("UPDATE sessions SET publication_mode = 'None' WHERE is_public = false", migration, StringComparison.Ordinal); + Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV030_ShouldRecreatePartialIndexUsingPublicationMode() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql"); + + Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal); + Assert.Contains("publication_mode IN ('Catalog', 'Both')", migration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV030_ShouldAddPortfolioPublicationModeColumn() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql"); + + Assert.Contains("portfolio_games", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_games_showcase", migration, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +}