Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 771ff9be34 | |||
| 29f6f6a827 | |||
| 6951c72f3c | |||
| 22e9859fdf | |||
| 6cb2fbe610 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.6.0
|
VERSION: 3.7.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.6.0</Version>
|
<Version>3.7.1</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.6.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.1
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.6.0
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.1
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.6.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.1
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
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,
|
int WaitlistedPlayerCount,
|
||||||
bool AllowDirectRegistration,
|
bool AllowDirectRegistration,
|
||||||
string? Description,
|
string? Description,
|
||||||
|
string PublicationMode = "None",
|
||||||
|
bool IsMembersOnly = false,
|
||||||
string? MasterProfileSlug = null,
|
string? MasterProfileSlug = null,
|
||||||
string? MasterDisplayName = null);
|
string? MasterDisplayName = null);
|
||||||
|
|||||||
@@ -41,6 +41,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Профиль
|
Профиль
|
||||||
</NavLink>
|
</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>
|
||||||
|
|
||||||
<div class="nav-footer">
|
<div class="nav-footer">
|
||||||
@@ -73,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.6.0</div>
|
<div class="nav-version">v3.7.1</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
<img src="/logo.png" alt="GM-Relay" />
|
<img src="/logo.png" alt="GM-Relay" />
|
||||||
<span>GM-Relay</span>
|
<span>GM-Relay</span>
|
||||||
</a>
|
</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>
|
</header>
|
||||||
|
|
||||||
<main class="public-content">
|
<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 class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
|
||||||
</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;">
|
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||||
@@ -104,6 +114,7 @@
|
|||||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||||
model.JoinLink = session.JoinLink;
|
model.JoinLink = session.JoinLink;
|
||||||
model.MaxPlayers = session.MaxPlayers;
|
model.MaxPlayers = session.MaxPlayers;
|
||||||
|
model.PublicationMode = session.PublicationMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleSubmit()
|
private async Task HandleSubmit()
|
||||||
@@ -123,6 +134,7 @@
|
|||||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||||
|
|
||||||
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||||
|
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
|
||||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
catch (SessionAccessDeniedException)
|
||||||
@@ -147,5 +159,6 @@
|
|||||||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||||||
public string JoinLink { get; set; } = "";
|
public string JoinLink { get; set; } = "";
|
||||||
public int? MaxPlayers { get; set; }
|
public int? MaxPlayers { get; set; }
|
||||||
|
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject AuthorizedSessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
@inject AuthorizedPortfolioService PortfolioService
|
@inject AuthorizedPortfolioService PortfolioService
|
||||||
|
@inject AuthorizedMembershipService MembershipService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
@@ -126,6 +127,14 @@
|
|||||||
</div>
|
</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))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
<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")">
|
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||||
@FormatBatchPublication(batch)
|
@FormatBatchPublication(batch)
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
|
||||||
@(IsBatchPublishBusy(batch)
|
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
|
||||||
? "Обновляем..."
|
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
|
||||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
|
||||||
</button>
|
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="batch-clone-row">
|
<div class="batch-clone-row">
|
||||||
@@ -369,11 +379,12 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="session-table-actions">
|
<div class="session-table-actions">
|
||||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
<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)">
|
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||||
@(publishingSessionId == session.Id
|
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||||
? "Обновляем..."
|
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||||
</button>
|
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||||
|
</select>
|
||||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||||
{
|
{
|
||||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||||
@@ -466,11 +477,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-card-actions">
|
<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)">
|
<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))">
|
||||||
@(publishingSessionId == session.Id
|
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||||
? "Обновляем..."
|
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||||
</button>
|
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||||
|
</select>
|
||||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
@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>
|
<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 IReadOnlyList<PortfolioGameSummary>? portfolioGames;
|
||||||
private List<BatchBulkEditModel> batchModels = [];
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||||
|
private int pendingApplicationsCount;
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
private Guid? processingTemplateId;
|
private Guid? processingTemplateId;
|
||||||
@@ -605,6 +618,8 @@
|
|||||||
|
|
||||||
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
||||||
|
|
||||||
|
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
|
||||||
|
|
||||||
RebuildBatchModels();
|
RebuildBatchModels();
|
||||||
RebuildCampaignTemplateModels();
|
RebuildCampaignTemplateModels();
|
||||||
RebuildPublicSettingsModel();
|
RebuildPublicSettingsModel();
|
||||||
@@ -664,7 +679,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
@@ -672,10 +687,14 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
|
||||||
successMessage = isPublic
|
successMessage = mode switch
|
||||||
? "Batch опубликован в публичном расписании."
|
{
|
||||||
: "Batch скрыт из публичного расписания.";
|
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
|
||||||
|
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
|
||||||
|
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
|
||||||
|
_ => "Batch скрыт из публичного расписания."
|
||||||
|
};
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
catch (SessionAccessDeniedException)
|
||||||
@@ -692,7 +711,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
@@ -700,10 +719,14 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
|
||||||
successMessage = isPublic
|
successMessage = mode switch
|
||||||
? "Сессия опубликована в публичном расписании."
|
{
|
||||||
: "Сессия скрыта из публичного расписания.";
|
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
|
||||||
|
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
|
||||||
|
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
|
||||||
|
_ => "Сессия скрыта из публичного расписания."
|
||||||
|
};
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
catch (SessionAccessDeniedException)
|
||||||
@@ -1073,7 +1096,13 @@
|
|||||||
IntervalDays = InferIntervalDays(orderedSessions),
|
IntervalDays = InferIntervalDays(orderedSessions),
|
||||||
SessionCount = orderedSessions.Count,
|
SessionCount = orderedSessions.Count,
|
||||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
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)
|
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||||
@@ -1220,6 +1249,9 @@
|
|||||||
: seats;
|
: seats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PublicationMode ParseMode(object? value) =>
|
||||||
|
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
|
||||||
|
|
||||||
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
|
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
|
||||||
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
|
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
|
||||||
|
|
||||||
@@ -1272,6 +1304,7 @@
|
|||||||
public int SessionCount { get; init; }
|
public int SessionCount { get; init; }
|
||||||
public int PublicSessionCount { get; init; }
|
public int PublicSessionCount { get; init; }
|
||||||
public bool AllSessionsPublic { get; init; }
|
public bool AllSessionsPublic { get; init; }
|
||||||
|
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
|
||||||
public string CloneInterval { get; set; } = "week";
|
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 ISessionStore SessionStore
|
||||||
@inject IPortfolioStore PortfolioStore
|
@inject IPortfolioStore PortfolioStore
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
|
@inject AuthorizedMembershipService MembershipService
|
||||||
|
@using System.Security.Claims
|
||||||
@using GmRelay.Web.Components.Portfolio
|
@using GmRelay.Web.Components.Portfolio
|
||||||
@using GmRelay.Web.Services.Portfolio
|
@using GmRelay.Web.Services.Portfolio
|
||||||
|
|
||||||
@@ -61,22 +64,79 @@ else if (club is not null)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="public-session-list">
|
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
|
||||||
@foreach (var session in club.Sessions)
|
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
|
||||||
{
|
|
||||||
<article class="public-session-card">
|
@if (publicSessions.Count > 0)
|
||||||
<div class="public-session-main">
|
{
|
||||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
<div class="public-session-list">
|
||||||
<h2>@session.Title</h2>
|
@foreach (var session in publicSessions)
|
||||||
<div class="public-session-meta">
|
{
|
||||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
<article class="public-session-card">
|
||||||
<span>@FormatSeats(session)</span>
|
<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>
|
</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>
|
</div>
|
||||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
}
|
||||||
</article>
|
else
|
||||||
}
|
{
|
||||||
</div>
|
<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)
|
@if (portfolioGames.Count > 0)
|
||||||
@@ -95,6 +155,33 @@ else if (club is not null)
|
|||||||
private WebPublicClub? club;
|
private WebPublicClub? club;
|
||||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||||
private bool loaded;
|
private bool loaded;
|
||||||
|
private Guid? viewerPlayerId;
|
||||||
|
private bool viewerIsActiveMember;
|
||||||
|
private string? applicationError;
|
||||||
|
private string? applicationMessage;
|
||||||
|
private bool isSubmittingApplication;
|
||||||
|
|
||||||
|
private async Task TrySubmitApplicationAsync()
|
||||||
|
{
|
||||||
|
applicationError = null;
|
||||||
|
if (club is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
isSubmittingApplication = true;
|
||||||
|
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
|
||||||
|
applicationMessage = null;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
applicationError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSubmittingApplication = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||||
|
|
||||||
@@ -107,12 +194,41 @@ else if (club is not null)
|
|||||||
{
|
{
|
||||||
loaded = false;
|
loaded = false;
|
||||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
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
|
club = trimmedSlug is null
|
||||||
? null
|
? null
|
||||||
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug);
|
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||||
portfolioGames = trimmedSlug is null
|
portfolioGames = trimmedSlug is null
|
||||||
? []
|
? []
|
||||||
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
|
: 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;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject ISessionStore SessionStore
|
@inject ISessionStore SessionStore
|
||||||
@inject IPortfolioStore PortfolioStore
|
@inject IPortfolioStore PortfolioStore
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
@using GmRelay.Web.Components.Portfolio
|
@using GmRelay.Web.Components.Portfolio
|
||||||
@using GmRelay.Web.Services.Portfolio
|
@using GmRelay.Web.Services.Portfolio
|
||||||
|
|
||||||
@@ -115,9 +116,20 @@ else if (profile is not null)
|
|||||||
{
|
{
|
||||||
loaded = false;
|
loaded = false;
|
||||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
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
|
profile = trimmedSlug is null
|
||||||
? null
|
? null
|
||||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug);
|
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||||
portfolioGames = trimmedSlug is null
|
portfolioGames = trimmedSlug is null
|
||||||
? []
|
? []
|
||||||
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
|
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ builder.Services.AddSingleton<DiscordAuthService>();
|
|||||||
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
||||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||||
|
builder.Services.AddScoped<AuthorizedMembershipService>();
|
||||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||||
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
||||||
builder.Services.AddScoped<AuthorizedPortfolioService>();
|
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);
|
normalizedBio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
|
||||||
{
|
{
|
||||||
var identity = GetCurrentIdentity();
|
var identity = GetCurrentIdentity();
|
||||||
if (identity is null)
|
if (identity is null)
|
||||||
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
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();
|
var identity = GetCurrentIdentity();
|
||||||
if (identity is null)
|
if (identity is null)
|
||||||
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
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)
|
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||||
|
|||||||
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
|
|||||||
int? MaxPlayers,
|
int? MaxPlayers,
|
||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
int WaitlistedPlayerCount,
|
int WaitlistedPlayerCount,
|
||||||
|
string PublicationMode = PublicationModeExtensions.NoneValue,
|
||||||
|
bool IsMembersOnly = false,
|
||||||
string? MasterProfileSlug = null,
|
string? MasterProfileSlug = null,
|
||||||
string? MasterDisplayName = 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(
|
public sealed record WebPublicClub(
|
||||||
Guid GroupId,
|
Guid GroupId,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -79,12 +120,14 @@ public interface ISessionStore
|
|||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
|
||||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
|
||||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
|
||||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<bool> IsGroupOwnerAsync(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<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||||
@@ -110,7 +153,7 @@ public interface ISessionStore
|
|||||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||||
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||||
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
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) ---
|
// --- Identity linking (issue #35) ---
|
||||||
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
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<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
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(
|
public sealed record LinkedIdentity(
|
||||||
|
|||||||
@@ -69,7 +69,19 @@ public sealed record WebSession(
|
|||||||
int WaitlistedPlayerCount,
|
int WaitlistedPlayerCount,
|
||||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||||
int? ThreadId = null,
|
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(
|
public sealed record WebParticipant(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
|
|||||||
bool AllowDirectRegistration,
|
bool AllowDirectRegistration,
|
||||||
string? Description,
|
string? Description,
|
||||||
string? MasterProfileSlug,
|
string? MasterProfileSlug,
|
||||||
string? MasterDisplayName);
|
string? MasterDisplayName,
|
||||||
|
string PublicationMode = "None",
|
||||||
|
bool IsMembersOnly = false);
|
||||||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
@@ -233,7 +247,7 @@ public sealed class SessionService(
|
|||||||
SELECT COUNT(*) AS count
|
SELECT COUNT(*) AS count
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.group_id = g.id
|
WHERE s.group_id = g.id
|
||||||
AND s.is_public = true
|
AND s.publication_mode IN ('Catalog', 'Both')
|
||||||
) public_counts ON true
|
) public_counts ON true
|
||||||
WHERE g.id = @GroupId
|
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();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var updatedRows = await conn.ExecuteAsync(
|
var updatedRows = await conn.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET is_public = @IsPublic,
|
SET publication_mode = @Mode,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = @SessionId
|
WHERE id = @SessionId
|
||||||
AND group_id = @GroupId
|
AND group_id = @GroupId
|
||||||
""",
|
""",
|
||||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||||
|
|
||||||
if (updatedRows == 0)
|
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();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var updatedRows = await conn.ExecuteAsync(
|
var updatedRows = await conn.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET is_public = @IsPublic,
|
SET publication_mode = @Mode,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
AND group_id = @GroupId
|
AND group_id = @GroupId
|
||||||
""",
|
""",
|
||||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||||
|
|
||||||
if (updatedRows == 0)
|
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();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||||
@@ -345,11 +359,11 @@ public sealed class SessionService(
|
|||||||
return null;
|
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);
|
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();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||||
@@ -364,6 +378,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
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.public_slug AS MasterProfileSlug,
|
||||||
mp.display_name AS MasterDisplayName
|
mp.display_name AS MasterDisplayName
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -404,9 +420,21 @@ public sealed class SessionService(
|
|||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
AND g.public_schedule_enabled = true
|
AND g.public_schedule_enabled = true
|
||||||
AND g.public_slug IS NOT NULL
|
AND g.public_slug IS NOT NULL
|
||||||
AND s.is_public = true
|
|
||||||
AND s.scheduled_at > now() - interval '4 hours'
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
AND s.status <> @Cancelled
|
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
|
new
|
||||||
{
|
{
|
||||||
@@ -414,7 +442,8 @@ public sealed class SessionService(
|
|||||||
Active = ParticipantRegistrationStatus.Active,
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||||
Cancelled = SessionStatus.Cancelled,
|
Cancelled = SessionStatus.Cancelled,
|
||||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||||
|
ViewerPlayerId = viewerPlayerId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +470,9 @@ public sealed class SessionService(
|
|||||||
s.allow_direct_registration AS AllowDirectRegistration,
|
s.allow_direct_registration AS AllowDirectRegistration,
|
||||||
s.description AS Description,
|
s.description AS Description,
|
||||||
mp.public_slug AS MasterProfileSlug,
|
mp.public_slug AS MasterProfileSlug,
|
||||||
mp.display_name AS MasterDisplayName
|
mp.display_name AS MasterDisplayName,
|
||||||
|
s.publication_mode AS PublicationMode,
|
||||||
|
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -479,7 +510,7 @@ public sealed class SessionService(
|
|||||||
AND mp.public_slug IS NOT NULL
|
AND mp.public_slug IS NOT NULL
|
||||||
WHERE g.public_schedule_enabled = true
|
WHERE g.public_schedule_enabled = true
|
||||||
AND g.public_slug IS NOT NULL
|
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.scheduled_at > now() - interval '4 hours'
|
||||||
AND s.status <> @Cancelled
|
AND s.status <> @Cancelled
|
||||||
AND (
|
AND (
|
||||||
@@ -518,7 +549,10 @@ public sealed class SessionService(
|
|||||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
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.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||||
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
r.Description,
|
||||||
|
PublicationMode: r.PublicationMode,
|
||||||
|
IsMembersOnly: r.IsMembersOnly,
|
||||||
|
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||||
@@ -544,7 +578,9 @@ public sealed class SessionService(
|
|||||||
s.allow_direct_registration AS AllowDirectRegistration,
|
s.allow_direct_registration AS AllowDirectRegistration,
|
||||||
s.description AS Description,
|
s.description AS Description,
|
||||||
mp.public_slug AS MasterProfileSlug,
|
mp.public_slug AS MasterProfileSlug,
|
||||||
mp.display_name AS MasterDisplayName
|
mp.display_name AS MasterDisplayName,
|
||||||
|
s.publication_mode AS PublicationMode,
|
||||||
|
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -583,7 +619,7 @@ public sealed class SessionService(
|
|||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
AND g.public_schedule_enabled = true
|
AND g.public_schedule_enabled = true
|
||||||
AND g.public_slug IS NOT NULL
|
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.scheduled_at > now() - interval '4 hours'
|
||||||
AND s.status <> @Cancelled
|
AND s.status <> @Cancelled
|
||||||
""",
|
""",
|
||||||
@@ -603,7 +639,10 @@ public sealed class SessionService(
|
|||||||
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
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.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||||
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||||
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
|
row.Description,
|
||||||
|
PublicationMode: row.PublicationMode,
|
||||||
|
IsMembersOnly: row.IsMembersOnly,
|
||||||
|
row.MasterProfileSlug, row.MasterDisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||||
@@ -617,7 +656,7 @@ public sealed class SessionService(
|
|||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId
|
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_schedule_enabled = true
|
||||||
AND g.public_slug IS NOT NULL
|
AND g.public_slug IS NOT NULL
|
||||||
AND s.scheduled_at > now() - interval '4 hours'
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
@@ -868,7 +907,7 @@ public sealed class SessionService(
|
|||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.is_public AS IsPublic
|
s.publication_mode AS PublicationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -907,7 +946,7 @@ public sealed class SessionService(
|
|||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.is_public AS IsPublic
|
s.publication_mode AS PublicationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -967,7 +1006,7 @@ public sealed class SessionService(
|
|||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.is_public AS IsPublic
|
s.publication_mode AS PublicationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||||
@@ -1054,7 +1093,7 @@ public sealed class SessionService(
|
|||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.is_public AS IsPublic
|
s.publication_mode AS PublicationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -1181,7 +1220,7 @@ public sealed class SessionService(
|
|||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.is_public AS IsPublic
|
s.publication_mode AS PublicationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -1951,7 +1990,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();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||||||
@@ -1971,7 +2010,7 @@ public sealed class SessionService(
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
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);
|
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,7 +2043,8 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||||||
NpgsqlConnection conn,
|
NpgsqlConnection conn,
|
||||||
Guid playerId)
|
Guid playerId,
|
||||||
|
Guid? viewerPlayerId)
|
||||||
{
|
{
|
||||||
return (await conn.QueryAsync<WebPublicSession>(
|
return (await conn.QueryAsync<WebPublicSession>(
|
||||||
"""
|
"""
|
||||||
@@ -2018,6 +2058,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
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.public_slug AS MasterProfileSlug,
|
||||||
mp.display_name AS MasterDisplayName
|
mp.display_name AS MasterDisplayName
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -2051,9 +2093,21 @@ public sealed class SessionService(
|
|||||||
) waitlist_counts ON true
|
) waitlist_counts ON true
|
||||||
WHERE g.public_schedule_enabled = true
|
WHERE g.public_schedule_enabled = true
|
||||||
AND g.public_slug IS NOT NULL
|
AND g.public_slug IS NOT NULL
|
||||||
AND s.is_public = true
|
|
||||||
AND s.scheduled_at > now() - interval '4 hours'
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
AND s.status <> @Cancelled
|
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
|
ORDER BY s.scheduled_at
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -2061,13 +2115,15 @@ public sealed class SessionService(
|
|||||||
PlayerId = playerId,
|
PlayerId = playerId,
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||||
Cancelled = SessionStatus.Cancelled
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
ViewerPlayerId = viewerPlayerId
|
||||||
})).ToList();
|
})).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||||
NpgsqlConnection conn,
|
NpgsqlConnection conn,
|
||||||
Guid groupId)
|
Guid groupId,
|
||||||
|
Guid? viewerPlayerId)
|
||||||
{
|
{
|
||||||
return (await conn.QueryAsync<WebPublicSession>(
|
return (await conn.QueryAsync<WebPublicSession>(
|
||||||
"""
|
"""
|
||||||
@@ -2081,6 +2137,8 @@ public sealed class SessionService(
|
|||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
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.public_slug AS MasterProfileSlug,
|
||||||
mp.display_name AS MasterDisplayName
|
mp.display_name AS MasterDisplayName
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -2121,9 +2179,21 @@ public sealed class SessionService(
|
|||||||
WHERE s.group_id = @GroupId
|
WHERE s.group_id = @GroupId
|
||||||
AND g.public_schedule_enabled = true
|
AND g.public_schedule_enabled = true
|
||||||
AND g.public_slug IS NOT NULL
|
AND g.public_slug IS NOT NULL
|
||||||
AND s.is_public = true
|
|
||||||
AND s.scheduled_at > now() - interval '4 hours'
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
AND s.status <> @Cancelled
|
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
|
ORDER BY s.scheduled_at
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -2132,7 +2202,8 @@ public sealed class SessionService(
|
|||||||
Active = ParticipantRegistrationStatus.Active,
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||||
Cancelled = SessionStatus.Cancelled,
|
Cancelled = SessionStatus.Cancelled,
|
||||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||||
|
ViewerPlayerId = viewerPlayerId
|
||||||
})).ToList();
|
})).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2432,4 +2503,248 @@ public sealed class SessionService(
|
|||||||
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
||||||
transaction);
|
transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Private club showcases / memberships (issue #110) ---
|
||||||
|
|
||||||
|
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
var count = await conn.ExecuteScalarAsync<long>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM club_memberships
|
||||||
|
WHERE group_id = @GroupId
|
||||||
|
AND player_id = @PlayerId
|
||||||
|
AND status = 'Active'
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, PlayerId = playerId });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
|
||||||
|
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return (await conn.QueryAsync<WebClubShowcaseSession>(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS Id,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS ScheduledAt,
|
||||||
|
s.status AS Status,
|
||||||
|
s.system AS System,
|
||||||
|
s.is_one_shot AS IsOneShot,
|
||||||
|
s.format AS Format,
|
||||||
|
s.duration_minutes AS DurationMinutes,
|
||||||
|
s.cover_image_url AS CoverImageUrl,
|
||||||
|
s.max_players AS MaxPlayers,
|
||||||
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
|
s.publication_mode AS PublicationMode,
|
||||||
|
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||||
|
s.description AS Description,
|
||||||
|
s.allow_direct_registration AS AllowDirectRegistration
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = s.id
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
) active_counts ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = s.id
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
) waitlist_counts ON true
|
||||||
|
WHERE s.group_id = @GroupId
|
||||||
|
AND g.public_schedule_enabled = true
|
||||||
|
AND g.public_slug IS NOT NULL
|
||||||
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
|
AND s.status <> @Cancelled
|
||||||
|
AND (
|
||||||
|
s.publication_mode IN ('Catalog', 'Both')
|
||||||
|
OR (
|
||||||
|
s.publication_mode = 'ClubOnly'
|
||||||
|
AND @ViewerPlayerId IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM club_memberships cm
|
||||||
|
WHERE cm.group_id = s.group_id
|
||||||
|
AND cm.player_id = @ViewerPlayerId
|
||||||
|
AND cm.status = 'Active'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY s.scheduled_at ASC
|
||||||
|
OFFSET @Offset LIMIT @PageSize
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
ViewerPlayerId = viewerPlayerId,
|
||||||
|
Offset = page * pageSize,
|
||||||
|
PageSize = pageSize
|
||||||
|
})).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return await conn.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int FROM club_memberships
|
||||||
|
WHERE group_id = @GroupId AND status = 'Pending'
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return (await conn.QueryAsync<WebPendingApplication>(
|
||||||
|
"""
|
||||||
|
SELECT cm.id AS MembershipId,
|
||||||
|
p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.platform AS Platform,
|
||||||
|
p.external_username AS ExternalUsername,
|
||||||
|
cm.message AS Message,
|
||||||
|
cm.applied_at AS AppliedAt
|
||||||
|
FROM club_memberships cm
|
||||||
|
JOIN players p ON p.id = cm.player_id
|
||||||
|
WHERE cm.group_id = @GroupId
|
||||||
|
AND cm.status = 'Pending'
|
||||||
|
ORDER BY cm.applied_at ASC
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId })).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return (await conn.QueryAsync<WebMembership>(
|
||||||
|
"""
|
||||||
|
SELECT cm.id AS MembershipId,
|
||||||
|
cm.group_id AS GroupId,
|
||||||
|
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
|
||||||
|
g.public_slug AS GroupSlug,
|
||||||
|
cm.status AS Status,
|
||||||
|
cm.role AS Role,
|
||||||
|
cm.message AS Message,
|
||||||
|
cm.applied_at AS AppliedAt,
|
||||||
|
cm.decided_at AS DecidedAt,
|
||||||
|
decider.display_name AS DecidedByDisplayName
|
||||||
|
FROM club_memberships cm
|
||||||
|
JOIN game_groups g ON g.id = cm.group_id
|
||||||
|
LEFT JOIN players decider ON decider.id = cm.decided_by
|
||||||
|
WHERE cm.player_id = @PlayerId
|
||||||
|
ORDER BY cm.applied_at DESC
|
||||||
|
""",
|
||||||
|
new { PlayerId = playerId })).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
var existing = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int FROM club_memberships
|
||||||
|
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, PlayerId = playerId });
|
||||||
|
if (existing > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Active or pending application already exists for this player.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await conn.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO club_memberships (group_id, player_id, status, message)
|
||||||
|
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, PlayerId = playerId, Message = message });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE club_memberships
|
||||||
|
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
|
||||||
|
WHERE id = @MembershipId AND status = 'Pending'
|
||||||
|
""",
|
||||||
|
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||||
|
if (rows == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE club_memberships
|
||||||
|
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
|
||||||
|
WHERE id = @MembershipId AND status = 'Pending'
|
||||||
|
""",
|
||||||
|
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||||
|
if (rows == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
// Active membership: withdraw by setting status = 'Left'.
|
||||||
|
var rows = await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE club_memberships
|
||||||
|
SET status = 'Left', decided_at = now()
|
||||||
|
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
|
||||||
|
""",
|
||||||
|
new { MembershipId = membershipId, PlayerId = playerId });
|
||||||
|
if (rows > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
|
||||||
|
var cancelled = await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE club_memberships
|
||||||
|
SET status = 'Rejected', decided_at = now()
|
||||||
|
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
|
||||||
|
""",
|
||||||
|
new { MembershipId = membershipId, PlayerId = playerId });
|
||||||
|
if (cancelled == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||||||
|
"""
|
||||||
|
SELECT group_id FROM club_memberships WHERE id = @MembershipId
|
||||||
|
""",
|
||||||
|
new { MembershipId = membershipId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class AuthorizedMembershipServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizedMembershipService_ShouldRequireAuthenticationForApply()
|
||||||
|
{
|
||||||
|
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("ApplyForCurrentUserAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("User is not authenticated", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("GetPlayerIdByPlatformIdentityAsync", service, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizedMembershipService_ShouldValidateMessageLength()
|
||||||
|
{
|
||||||
|
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("1000", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("н", service, StringComparison.Ordinal); // Russian message length
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizedMembershipService_ShouldRestrictGmActionsToGroupManagers()
|
||||||
|
{
|
||||||
|
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ResolveMembershipContextForGmAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("GetGroupIdForMembershipAsync", service, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizedMembershipService_ShouldExposePendingApplications()
|
||||||
|
{
|
||||||
|
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("GetPendingApplicationsAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("GetPendingApplicationsCountForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("GetMineAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("LeaveClubForCurrentUserAsync", service, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -794,10 +794,21 @@ public sealed class AuthorizedPortfolioServiceTests
|
|||||||
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
|
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
|
||||||
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(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 UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
|
||||||
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException();
|
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
|
||||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException();
|
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
|
||||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException();
|
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
||||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) => 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<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
|
||||||
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
|
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
|
||||||
public Task<List<WebSession>> GetUpcomingSessionsAsync(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 UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
|
||||||
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) => 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 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<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 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();
|
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? LastPublicSessionId { get; private set; }
|
||||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||||
public bool? LastSessionPublicValue { get; private set; }
|
public bool? LastSessionPublicValue { get; private set; }
|
||||||
|
public PublicationMode? LastSessionPublicationMode { get; private set; }
|
||||||
public Guid? LastPublicBatchId { get; private set; }
|
public Guid? LastPublicBatchId { get; private set; }
|
||||||
public Guid? LastPublicBatchGroupId { get; private set; }
|
public Guid? LastPublicBatchGroupId { get; private set; }
|
||||||
public bool? LastBatchPublicValue { get; private set; }
|
public bool? LastBatchPublicValue { get; private set; }
|
||||||
|
public PublicationMode? LastBatchPublicationMode { get; private set; }
|
||||||
public bool RemovePlayerCalled { get; private set; }
|
public bool RemovePlayerCalled { get; private set; }
|
||||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||||
@@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||||||
{
|
{
|
||||||
SetSessionPublicCalled = true;
|
SetSessionPublicCalled = true;
|
||||||
LastPublicSessionId = sessionId;
|
LastPublicSessionId = sessionId;
|
||||||
LastPublicSessionGroupId = groupId;
|
LastPublicSessionGroupId = groupId;
|
||||||
LastSessionPublicValue = isPublic;
|
LastSessionPublicationMode = mode;
|
||||||
|
|
||||||
if (sessionsById.TryGetValue(sessionId, out var session))
|
if (sessionsById.TryGetValue(sessionId, out var session))
|
||||||
{
|
{
|
||||||
sessionsById[sessionId] = session with { IsPublic = isPublic };
|
sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() };
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||||||
{
|
{
|
||||||
SetBatchPublicCalled = true;
|
SetBatchPublicCalled = true;
|
||||||
LastPublicBatchId = batchId;
|
LastPublicBatchId = batchId;
|
||||||
LastPublicBatchGroupId = groupId;
|
LastPublicBatchGroupId = groupId;
|
||||||
LastBatchPublicValue = isPublic;
|
LastBatchPublicationMode = mode;
|
||||||
|
|
||||||
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
|
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
|
||||||
Task.FromResult<WebPublicClub?>(null);
|
Task.FromResult<WebPublicClub?>(null);
|
||||||
|
|
||||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
|
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
|
||||||
Task.FromResult<WebPublicSession?>(null);
|
Task.FromResult<WebPublicSession?>(null);
|
||||||
|
|
||||||
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||||
Task.FromResult(IsManager(groupId, 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) =>
|
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
|
||||||
Task.FromResult(IsOwner(groupId, telegramId));
|
Task.FromResult(IsOwner(groupId, telegramId));
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||||
{
|
{
|
||||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
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.1", navMenu, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
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]
|
[Fact]
|
||||||
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
|
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
|
||||||
{
|
{
|
||||||
@@ -40,10 +54,10 @@ public sealed class PublicClubPagesTests
|
|||||||
|
|
||||||
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
|
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||||
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
|
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
|
||||||
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
|
Assert.Contains("SetSessionPublicationModeAsync", sessionStore, StringComparison.Ordinal);
|
||||||
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
|
Assert.Contains("SetBatchPublicationModeAsync", sessionStore, StringComparison.Ordinal);
|
||||||
Assert.Contains("g.public_schedule_enabled = true", service, 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.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(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");
|
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||||
|
|
||||||
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||||
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
Assert.Contains("SetSessionPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||||
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
Assert.Contains("SetBatchPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||||
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
|
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
|
||||||
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
|
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
|
||||||
Assert.Contains("IsGroupManagerAsync", 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