Merge pull request #119: feat(web): private club showcases with membership flow (v3.7.0, issue #110)
Deploy Telegram Bot / build-and-push (push) Successful in 5m29s
Deploy Telegram Bot / scan-images (push) Successful in 1m29s
Deploy Telegram Bot / deploy (push) Successful in 39s

This commit is contained in:
2026-06-03 11:46:01 +03:00
27 changed files with 1610 additions and 109 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.6.0
VERSION: 3.7.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.6.0</Version>
<Version>3.7.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -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:
@@ -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');
@@ -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.")
};
}
@@ -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);
@@ -41,6 +41,15 @@
</svg>
Профиль
</NavLink>
<NavLink class="nav-item" href="profile/memberships" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l8-4v18"/>
<path d="M19 21V11l-6-4"/>
</svg>
Мои клубы
</NavLink>
</div>
<div class="nav-footer">
@@ -73,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.6.0</div>
<div class="nav-version">v3.7.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -6,7 +6,10 @@
<img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span>
</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
<div class="public-topbar-actions">
<a class="btn-gm btn-gm-outline" href="/showcase">Клубы</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
</div>
</header>
<main class="public-content">
@@ -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
<PageTitle>Заявки участников — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="@($"/group/{GroupId}")">Группа</a></li>
<li class="active">Заявки</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📨 Заявки участников</h2>
<p>Одобряйте или отклоняйте заявки на участие в клубе.</p>
</div>
@if (accessDenied)
{
<div class="glass-card public-empty-state">
<h2>Нет доступа</h2>
<p>Только owner или co-GM группы могут просматривать заявки.</p>
</div>
}
else if (applications is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 70%; height: 2rem; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 90%;"></div>
</div>
}
else if (applications.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Новых заявок нет</h2>
<p>Когда игроки подадут заявку на участие в клубе, она появится здесь.</p>
</div>
}
else
{
<ul class="application-list">
@foreach (var app in applications)
{
<li class="glass-card application-item">
<div class="application-info">
<strong>@app.DisplayName</strong>
<span class="status-badge status-neutral">@app.Platform</span>
@if (!string.IsNullOrWhiteSpace(app.ExternalUsername))
{
<span class="application-meta">@app.ExternalUsername</span>
}
<span class="application-meta">@app.AppliedAt.ToString("dd.MM.yyyy HH:mm")</span>
@if (!string.IsNullOrWhiteSpace(app.Message))
{
<p class="application-message">«@app.Message»</p>
}
</div>
<div class="application-actions">
<button type="button" class="btn-gm btn-gm-success" disabled="@(busyMembershipId is not null)" @onclick="() => Approve(app.MembershipId)">
✅ Одобрить
</button>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(busyMembershipId is not null)" @onclick="() => Reject(app.MembershipId)">
❌ Отклонить
</button>
</div>
</li>
}
</ul>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
}
</div>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebPendingApplication>? 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;
}
}
}
@@ -57,6 +57,16 @@
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Режим публикации</label>
<InputSelect @bind-Value="model.PublicationMode" class="gm-form-control">
<option value="@PublicationModeExtensions.NoneValue">Скрыта</option>
<option value="@PublicationModeExtensions.CatalogValue">В каталоге</option>
<option value="@PublicationModeExtensions.ClubOnlyValue">Только для участников клуба</option>
<option value="@PublicationModeExtensions.BothValue">Каталог + клуб</option>
</InputSelect>
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
@@ -104,6 +114,7 @@
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers;
model.PublicationMode = session.PublicationMode;
}
private async Task HandleSubmit()
@@ -123,6 +134,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
@@ -147,5 +159,6 @@
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
public string JoinLink { get; set; } = "";
public int? MaxPlayers { get; set; }
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
}
}
@@ -7,6 +7,7 @@
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthorizedPortfolioService PortfolioService
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -126,6 +127,14 @@
</div>
}
@if (pendingApplicationsCount > 0)
{
<a class="glass-card applications-card" href="@($"/group/{GroupId}/applications")">
<span class="status-badge status-warning">📨 Заявки участников (@pendingApplicationsCount)</span>
<span>Рассмотреть заявки на участие в клубе</span>
</a>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -313,11 +322,12 @@
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
@(IsBatchPublishBusy(batch)
? "Обновляем..."
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
</button>
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
</select>
</div>
<div class="batch-clone-row">
@@ -369,11 +379,12 @@
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
@@ -466,11 +477,12 @@
</div>
</div>
<div class="session-card-actions">
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
<select class="gm-form-control" style="flex: 1; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
@@ -540,6 +552,7 @@
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> 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<PublicationMode>(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";
}
@@ -0,0 +1,147 @@
@page "/profile/memberships"
@using GmRelay.Web.Services
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject AuthorizedMembershipService MembershipService
@inject NavigationManager Navigation
<PageTitle>Мои клубы — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Мои клубы</li>
</ul>
<div class="page-header animate-fade-in">
<h2>🏛 Мои клубы</h2>
<p>Заявки и активные участия в приватных клубных витринах.</p>
</div>
@if (memberships is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 80%;"></div>
</div>
}
else if (memberships.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Вы пока не подавали заявок</h2>
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
</div>
}
else
{
@if (activeMemberships.Count > 0)
{
<section class="glass-card animate-slide-up">
<h3>Активные участия</h3>
<ul class="membership-list">
@foreach (var membership in activeMemberships)
{
<li>
<div class="membership-info">
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
@membership.GroupName
</a>
<span class="status-badge status-success">Участник</span>
</div>
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
Покинуть клуб
</button>
</li>
}
</ul>
</section>
}
@if (pendingMemberships.Count > 0)
{
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
<h3>Заявки на рассмотрении</h3>
<ul class="membership-list">
@foreach (var membership in pendingMemberships)
{
<li>
<div class="membership-info">
<span class="membership-name">@membership.GroupName</span>
<span class="status-badge status-warning">Ожидает одобрения</span>
</div>
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
Отозвать заявку
</button>
</li>
}
</ul>
</section>
}
@if (historyMemberships.Count > 0)
{
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
<h3>История</h3>
<ul class="membership-list">
@foreach (var membership in historyMemberships)
{
<li>
<div class="membership-info">
<span class="membership-name">@membership.GroupName</span>
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
</span>
@if (membership.DecidedAt is not null)
{
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
}
</div>
</li>
}
</ul>
</section>
}
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
}
</div>
@code {
private List<WebMembership>? memberships;
private List<WebMembership> activeMemberships = [];
private List<WebMembership> pendingMemberships = [];
private List<WebMembership> 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;
}
}
}
+131 -15
View File
@@ -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
{
<div class="public-session-list">
@foreach (var session in club.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
@if (publicSessions.Count > 0)
{
<div class="public-session-list">
@foreach (var session in publicSessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
@if (membersOnlySessions.Count > 0)
{
<section class="glass-card members-only-section">
<h2>Игры для участников клуба</h2>
@if (viewerIsActiveMember)
{
<div class="public-session-list">
@foreach (var session in membersOnlySessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge status-warning">Только для участников</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
else
{
<p>Эти сессии доступны только одобренным участникам клуба.</p>
@if (viewerPlayerId is null)
{
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
}
else
{
<details class="application-form">
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
<div class="gm-form-group">
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
</div>
@if (!string.IsNullOrEmpty(applicationError))
{
<p class="form-error">@applicationError</p>
}
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
</EditForm>
</details>
}
}
</section>
}
}
@if (portfolioGames.Count > 0)
@@ -95,6 +155,33 @@ else if (club is not null)
private WebPublicClub? club;
private IReadOnlyList<PublicPortfolioCard> 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;
}
@@ -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);
+1
View File
@@ -45,6 +45,7 @@ builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<AuthorizedMembershipService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
builder.Services.AddScoped<AuthorizedPortfolioService>();
@@ -0,0 +1,124 @@
using System.Security.Claims;
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedMembershipService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
{
private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
{
var user = httpContextAccessor.HttpContext?.User;
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
return null;
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
return (platform, externalUserId, name);
}
public async Task<Guid> ApplyForCurrentUserAsync(Guid groupId, string? message)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
{
throw new InvalidOperationException("Player record not found for current user.");
}
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
if (normalizedMessage?.Length > 1000)
{
throw new InvalidOperationException("Сообщение заявки должно быть не длиннее 1000 символов.");
}
return await sessionStore.ApplyForMembershipAsync(groupId, playerId.Value, normalizedMessage);
}
public async Task<List<WebMembership>> GetMineAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return [];
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
return [];
return await sessionStore.GetMembershipsForPlayerAsync(playerId.Value);
}
public async Task LeaveClubForCurrentUserAsync(Guid membershipId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
throw new InvalidOperationException("Player record not found for current user.");
await sessionStore.LeaveClubMembershipAsync(membershipId, playerId.Value);
}
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
}
return await sessionStore.GetPendingApplicationsAsync(groupId);
}
public async Task<int> GetPendingApplicationsCountForCurrentGmAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return 0;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return 0;
return await sessionStore.GetPendingApplicationsCountAsync(groupId);
}
public async Task ApproveForCurrentGmAsync(Guid membershipId)
{
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
await sessionStore.ApproveMembershipAsync(membershipId, approverPlayerId);
}
public async Task RejectForCurrentGmAsync(Guid membershipId)
{
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
await sessionStore.RejectMembershipAsync(membershipId, approverPlayerId);
}
private async Task<(Guid ApproverPlayerId, Guid GroupId)> ResolveMembershipContextForGmAsync(Guid membershipId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
throw new InvalidOperationException("Player record not found for current user.");
var groupId = await sessionStore.GetGroupIdForMembershipAsync(membershipId);
if (groupId is null)
throw new InvalidOperationException($"Membership {membershipId} not found.");
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId.Value, identity.Value.ExternalUserId);
}
return (playerId.Value, groupId.Value);
}
}
@@ -136,7 +136,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
normalizedBio);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
{
var identity = GetCurrentIdentity();
if (identity is null)
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
await sessionStore.SetSessionPublicationModeAsync(sessionId, session.GroupId, mode);
}
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
public async Task SetBatchPublicationModeForCurrentUserAsync(Guid batchId, PublicationMode mode)
{
var identity = GetCurrentIdentity();
if (identity is null)
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
await sessionStore.SetBatchPublicationModeAsync(batchId, batch.GroupId, mode);
}
public async Task<bool> IsActiveClubMemberForCurrentUserAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return false;
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
return false;
return await sessionStore.IsActiveClubMemberAsync(groupId, playerId.Value);
}
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
+59 -5
View File
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string PublicationMode = PublicationModeExtensions.NoneValue,
bool IsMembersOnly = false,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record WebMembership(
Guid MembershipId,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Status,
string Role,
string? Message,
DateTime AppliedAt,
DateTime? DecidedAt,
string? DecidedByDisplayName);
public sealed record WebPendingApplication(
Guid MembershipId,
Guid PlayerId,
string DisplayName,
string Platform,
string? ExternalUsername,
string? Message,
DateTime AppliedAt);
public sealed record WebClubShowcaseSession(
Guid Id,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string PublicationMode,
bool IsMembersOnly,
string? Description,
bool AllowDirectRegistration);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
@@ -79,12 +120,14 @@ public interface ISessionStore
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -110,7 +153,7 @@ public interface ISessionStore
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
@@ -123,6 +166,17 @@ public interface ISessionStore
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
// --- Private club showcases / memberships (issue #110) ---
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
}
public sealed record LinkedIdentity(
+343 -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,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<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();
// 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<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 });
}
}
@@ -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<string> 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}'.");
}
}
@@ -794,10 +794,21 @@ public sealed class AuthorizedPortfolioServiceTests
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
public Task<WebPublicGroupSettings?> 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<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException();
public Task<WebPublicSession?> 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<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
public Task<Guid> 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<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException();
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebSession>> 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<MasterProfileSettings?> 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<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException();
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<List<LinkedIdentity>> 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();
@@ -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<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicClub?>(null);
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicSession?>(null);
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) =>
Task.FromResult(false);
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(null);
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) =>
Task.FromResult<IReadOnlyList<WebClubShowcaseSession>>([]);
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) =>
Task.FromResult(0);
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) =>
Task.FromResult(new List<WebPendingApplication>());
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) =>
Task.FromResult(new List<WebMembership>());
public Task<Guid> 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<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) =>
Task.FromResult<Guid?>(null);
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<PublicMasterProfile?>(null);
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId));
@@ -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]
@@ -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<string> 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}'.");
}
}
@@ -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<string> 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}'.");
}
}
@@ -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);
@@ -0,0 +1,79 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class PublicationModeTests
{
[Fact]
public void PublicationMode_ShouldHaveFourValues()
{
var values = Enum.GetValues<GmRelay.Shared.Domain.PublicationMode>();
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<string> 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}'.");
}
}