feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions exclusively to a club's private showcase, gated behind a member application and approval flow. Adds a 4-state publication_mode (None/Catalog/ClubOnly/Both) replacing the binary is_public, plus a club_memberships table with Pending/Active/Rejected/Left lifecycle and partial unique index ensuring a single Active row per (group, player). Highlights - V030 migration: club_memberships, publication_mode, drop is_public, recreate partial indexes, portfolio_games gains publication_mode. - PublicationMode enum + extensions in GmRelay.Shared. - ISessionStore gains 12 membership/showcase methods; AuthorizedMembershipService owns the membership flow with GM-only approve/reject authorization. - PublicClub / PublicMasterProfile / PublicSession: member- aware queries (ClubOnly visible only to Active members). - New pages: MyClubMemberships (/profile/memberships) and ClubApplications (/group/{id}/applications). - GroupDetails and EditSession switch from a bool toggle to a 4-state publication_mode selector. - NavMenu adds Moji kluby, PublicLayout adds Kluby. Tests: 4 new test files (PublicationMode, ClubMemberships, AuthorizedMembershipService, ClubShowcaseSource) + updates to PublicClubPages, AuthorizedSessionService/Portfolio service FakeSessionStore, CampaignTemplatesNavigation. 493 tests pass. Bump version 3.6.0 -> 3.7.0 across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.6.0
|
||||
VERSION: 3.7.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,39 @@ 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;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(applicationMessage))
|
||||
{
|
||||
applicationError = "Введите сообщение или оставьте поле пустым.";
|
||||
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 +200,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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue,
|
||||
bool IsMembersOnly = false,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
public sealed record WebMembership(
|
||||
Guid MembershipId,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Status,
|
||||
string Role,
|
||||
string? Message,
|
||||
DateTime AppliedAt,
|
||||
DateTime? DecidedAt,
|
||||
string? DecidedByDisplayName);
|
||||
|
||||
public sealed record WebPendingApplication(
|
||||
Guid MembershipId,
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
string Platform,
|
||||
string? ExternalUsername,
|
||||
string? Message,
|
||||
DateTime AppliedAt);
|
||||
|
||||
public sealed record WebClubShowcaseSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode,
|
||||
bool IsMembersOnly,
|
||||
string? Description,
|
||||
bool AllowDirectRegistration);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
@@ -79,12 +120,14 @@ public interface ISessionStore
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
|
||||
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
|
||||
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
@@ -110,7 +153,7 @@ public interface ISessionStore
|
||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
|
||||
// --- Identity linking (issue #35) ---
|
||||
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
||||
@@ -123,6 +166,17 @@ public interface ISessionStore
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
|
||||
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
|
||||
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
|
||||
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
|
||||
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
|
||||
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
|
||||
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
|
||||
}
|
||||
|
||||
public sealed record LinkedIdentity(
|
||||
|
||||
@@ -69,7 +69,19 @@ public sealed record WebSession(
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||
int? ThreadId = null,
|
||||
bool IsPublic = false);
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue)
|
||||
{
|
||||
public bool IsPublic
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
|
||||
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
|
||||
}
|
||||
|
||||
public sealed record WebParticipant(
|
||||
Guid Id,
|
||||
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string? MasterProfileSlug,
|
||||
string? MasterDisplayName);
|
||||
string? MasterDisplayName,
|
||||
string PublicationMode = "None",
|
||||
bool IsMembersOnly = false);
|
||||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||||
|
||||
public sealed class SessionService(
|
||||
@@ -233,7 +247,7 @@ public sealed class SessionService(
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
@@ -266,18 +280,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -285,18 +299,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -304,7 +318,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
@@ -345,11 +359,11 @@ public sealed class SessionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
@@ -364,6 +378,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -404,9 +420,21 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
""",
|
||||
new
|
||||
{
|
||||
@@ -414,7 +442,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +508,7 @@ public sealed class SessionService(
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
@@ -518,7 +547,10 @@ public sealed class SessionService(
|
||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
r.Description,
|
||||
PublicationMode: "Catalog",
|
||||
IsMembersOnly: false,
|
||||
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||
@@ -583,7 +615,7 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
@@ -603,7 +635,10 @@ public sealed class SessionService(
|
||||
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
||||
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
|
||||
row.Description,
|
||||
PublicationMode: "Catalog",
|
||||
IsMembersOnly: false,
|
||||
row.MasterProfileSlug, row.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||
@@ -617,7 +652,7 @@ public sealed class SessionService(
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
@@ -868,7 +903,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -907,7 +942,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -967,7 +1002,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -1054,7 +1089,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1181,7 +1216,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1951,7 +1986,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||||
@@ -1971,7 +2006,7 @@ public sealed class SessionService(
|
||||
return null;
|
||||
|
||||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
|
||||
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||||
}
|
||||
|
||||
@@ -2004,7 +2039,8 @@ public sealed class SessionService(
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid playerId)
|
||||
Guid playerId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2018,6 +2054,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2051,9 +2089,21 @@ public sealed class SessionService(
|
||||
) waitlist_counts ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2061,13 +2111,15 @@ public sealed class SessionService(
|
||||
PlayerId = playerId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
Guid groupId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2081,6 +2133,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2121,9 +2175,21 @@ public sealed class SessionService(
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2132,7 +2198,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
@@ -2432,4 +2499,234 @@ public sealed class SessionService(
|
||||
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
|
||||
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var count = await conn.ExecuteScalarAsync<long>(
|
||||
"""
|
||||
SELECT COUNT(*) FROM club_memberships
|
||||
WHERE group_id = @GroupId
|
||||
AND player_id = @PlayerId
|
||||
AND status = 'Active'
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
|
||||
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebClubShowcaseSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.system AS System,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.format AS Format,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.cover_image_url AS CoverImageUrl,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
s.description AS Description,
|
||||
s.allow_direct_registration AS AllowDirectRegistration
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at ASC
|
||||
OFFSET @Offset LIMIT @PageSize
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId,
|
||||
Offset = page * pageSize,
|
||||
PageSize = pageSize
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND status = 'Pending'
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebPendingApplication>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.platform AS Platform,
|
||||
p.external_username AS ExternalUsername,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt
|
||||
FROM club_memberships cm
|
||||
JOIN players p ON p.id = cm.player_id
|
||||
WHERE cm.group_id = @GroupId
|
||||
AND cm.status = 'Pending'
|
||||
ORDER BY cm.applied_at ASC
|
||||
""",
|
||||
new { GroupId = groupId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebMembership>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
cm.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
cm.status AS Status,
|
||||
cm.role AS Role,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt,
|
||||
cm.decided_at AS DecidedAt,
|
||||
decider.display_name AS DecidedByDisplayName
|
||||
FROM club_memberships cm
|
||||
JOIN game_groups g ON g.id = cm.group_id
|
||||
LEFT JOIN players decider ON decider.id = cm.decided_by
|
||||
WHERE cm.player_id = @PlayerId
|
||||
ORDER BY cm.applied_at DESC
|
||||
""",
|
||||
new { PlayerId = playerId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var existing = await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
if (existing > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Active or pending application already exists for this player.");
|
||||
}
|
||||
|
||||
return await conn.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO club_memberships (group_id, player_id, status, message)
|
||||
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
|
||||
RETURNING id
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId, Message = message });
|
||||
}
|
||||
|
||||
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Left', decided_at = now()
|
||||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
|
||||
""",
|
||||
new { MembershipId = membershipId, PlayerId = playerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Active membership {membershipId} not found for player.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT group_id FROM club_memberships WHERE id = @MembershipId
|
||||
""",
|
||||
new { MembershipId = membershipId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user