Compare commits

...

3 Commits

Author SHA1 Message Date
Toutsu b32f962f11 Merge pull request #113: feat(web): add public master profiles
Deploy Telegram Bot / build-and-push (push) Successful in 6m20s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 28s
2026-05-29 00:22:43 +03:00
Toutsu 0c1d3abd7e feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.

Bump version -> 3.5.0
2026-05-29 00:08:14 +03:00
Toutsu d81564c308 Merge pull request #109: feat: добавить каталог игр и витрину ваншотов (issue #39)
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 17:56:53 +03:00
21 changed files with 980 additions and 39 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.4.0
VERSION: 3.5.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.4.0</Version>
<Version>3.5.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+2 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.4.0`.
**Текущая версия:** `v3.5.0`.
---
@@ -38,6 +38,7 @@
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
- **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.4.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.4.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.0
restart: always
depends_on:
db:
@@ -84,7 +84,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.4.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.0
restart: always
depends_on:
db:
+7 -7
View File
@@ -8,19 +8,19 @@ C4Context
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
Person(visitor, "Public visitor", "Views published club schedules without private player data")
Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles without private player data")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile pages, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(visitor, gmrelay, "Views public club and session pages")
Rel(visitor, gmrelay, "Views public club, session, and GM profile pages")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
@@ -41,9 +41,9 @@ C4Container
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile pages, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
@@ -53,7 +53,7 @@ C4Container
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(visitor, web, "Read-only public schedule pages")
Rel(visitor, web, "Read-only public schedule and sanitized GM profile pages")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
@@ -0,0 +1,20 @@
-- Public GM profiles for catalog and club trust pages.
CREATE TABLE master_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
public_slug VARCHAR(120),
is_public BOOLEAN NOT NULL DEFAULT false,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ux_master_profiles_public_slug
ON master_profiles (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_master_profiles_public
ON master_profiles (lower(public_slug))
WHERE is_public = true AND public_slug IS NOT NULL;
@@ -17,4 +17,6 @@ public sealed record ShowcaseSessionDto(
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description);
string? Description,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
@@ -73,7 +73,7 @@
</button>
</form>
<div class="nav-version">v3.4.0</div>
<div class="nav-version">v3.5.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -4,6 +4,7 @@
@using Microsoft.Extensions.Configuration
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthorizedSessionService AuthorizedSessionService
@inject IConfiguration Configuration
@inject NavigationManager Navigation
@@ -12,6 +13,65 @@
<div class="profile-container">
<h1 class="page-title">Профиль</h1>
@if (masterProfile is not null)
{
<div class="profile-card master-profile-card">
<div class="profile-card-header">
<div>
<h2 class="section-title">Публичный профиль мастера</h2>
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
</div>
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
</div>
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
<span>Опубликовать профиль</span>
</label>
</div>
<div class="profile-form-grid">
<div class="gm-form-group">
<label class="gm-form-label">Имя в публичном профиле</label>
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Описание</label>
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
</button>
@if (PublicMasterProfileUrl is not null)
{
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичный профиль
</a>
}
</div>
</EditForm>
@if (PublicMasterProfileUrl is not null)
{
<div class="public-link-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
}
</div>
}
@if (identities is null)
{
<p class="loading-text">Загрузка...</p>
@@ -92,11 +152,14 @@
@code {
private List<LinkedIdentity>? identities;
private MasterProfileSettings? masterProfile;
private string? currentPlatform;
private string? currentExternalUserId;
private bool isUnlinking;
private bool savingMasterProfile;
private string? errorMessage;
private string? successMessage;
private MasterProfileEditModel masterProfileModel = new();
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
@@ -131,6 +194,7 @@
}
await LoadIdentities();
await LoadMasterProfile();
}
private async Task LoadIdentities()
@@ -152,6 +216,60 @@
}
}
private async Task LoadMasterProfile()
{
try
{
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
if (masterProfile is not null)
{
masterProfileModel = new MasterProfileEditModel
{
DisplayName = masterProfile.DisplayName,
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
IsPublic = masterProfile.IsPublic,
Bio = masterProfile.Bio ?? string.Empty
};
}
}
catch (Exception ex)
{
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
}
}
private string? PublicMasterProfileUrl =>
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
: null;
private async Task SaveMasterProfile()
{
savingMasterProfile = true;
errorMessage = null;
successMessage = null;
try
{
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
masterProfileModel.PublicSlug,
masterProfileModel.IsPublic,
masterProfileModel.DisplayName,
masterProfileModel.Bio);
successMessage = "Публичный профиль мастера обновлён.";
await LoadMasterProfile();
}
catch (Exception ex)
{
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
}
finally
{
savingMasterProfile = false;
}
}
private bool HasLinkedPlatform(string platform)
{
return identities?.Any(i => i.Platform == platform) ?? false;
@@ -188,4 +306,12 @@
isUnlinking = false;
}
}
private sealed class MasterProfileEditModel
{
public string DisplayName { get; set; } = string.Empty;
public string PublicSlug { get; set; } = string.Empty;
public bool IsPublic { get; set; }
public string Bio { get; set; } = string.Empty;
}
}
@@ -38,6 +38,15 @@ else if (club is not null)
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
{
<div class="public-share-row">
<span>Мастер</span>
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
@(club.MasterDisplayName ?? "Профиль мастера")
</a>
</div>
}
</section>
@if (club.Sessions.Count == 0)
@@ -92,6 +101,8 @@ else if (club is not null)
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
@@ -0,0 +1,136 @@
@page "/gm/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && profile is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Профиль мастера не найден</h1>
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (profile is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
</HeadContent>
<section class="public-hero public-hero-compact master-profile-hero">
<span class="status-badge status-success">Мастер</span>
<h1>@profile.DisplayName</h1>
@if (!string.IsNullOrWhiteSpace(profile.Bio))
{
<p>@profile.Bio</p>
}
<div class="public-share-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
</section>
@if (profile.Clubs.Count > 0)
{
<section class="glass-card master-profile-section">
<h2>Клубы</h2>
<div class="master-profile-club-list">
@foreach (var club in profile.Clubs)
{
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
}
</div>
</section>
}
@if (profile.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
</div>
}
else
{
<div class="public-session-list">
@foreach (var session in profile.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.GroupName</span>
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
</article>
}
</div>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private GmRelay.Web.Services.PublicMasterProfile? profile;
private bool loaded;
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
private string PublicMasterProfileUrl =>
profile is null
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
profile = string.IsNullOrWhiteSpace(Slug)
? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
loaded = true;
}
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -42,6 +42,13 @@ else if (session is not null)
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h1>@session.Title</h1>
<p>@session.GroupName</p>
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
{
<div class="public-master-link">
<span>Мастер</span>
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
</div>
}
<div class="session-badges">
@if (!string.IsNullOrWhiteSpace(session.System))
{
@@ -101,6 +108,10 @@ else if (session is not null)
{
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
}
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
{
<a class="btn-gm btn-gm-outline" href="@MasterProfilePath(session.MasterProfileSlug)">Мастер</a>
}
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
@if (session.AllowDirectRegistration)
{
@@ -129,6 +140,8 @@ else if (session is not null)
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
protected override async Task OnParametersSetAsync()
{
loaded = false;
@@ -130,6 +130,12 @@ else
<div class="showcase-card-club">
<span>@session.GroupName</span>
</div>
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
{
<div class="showcase-card-master">
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
</div>
}
<div class="showcase-card-actions">
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
@if (session.AllowDirectRegistration)
@@ -254,7 +260,8 @@ else
.showcase-card-meta,
.showcase-card-seats,
.showcase-card-club {
.showcase-card-club,
.showcase-card-master {
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'Jura', sans-serif;
@@ -264,6 +271,12 @@ else
color: var(--text-muted);
}
.showcase-card-master a {
color: var(--accent-primary);
text-decoration: none;
font-weight: 600;
}
.showcase-card-actions {
margin-top: auto;
padding-top: 0.75rem;
@@ -428,6 +441,8 @@ else
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
}
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
private static string TranslateFormat(string format) => format switch
{
"Online" => "Онлайн",
+6
View File
@@ -161,6 +161,7 @@ app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService aut
app.MapPost("/auth/telegram-webapp", async (
HttpContext context,
TelegramAuthService authService,
ISessionStore sessionStore,
TelegramWebAppAuthRequest request) =>
{
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
@@ -168,6 +169,8 @@ app.MapPost("/auth/telegram-webapp", async (
return Results.Unauthorized();
}
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
@@ -180,6 +183,7 @@ app.MapPost("/auth/telegram-webapp", async (
app.MapPost("/auth/telegram-login", async (
HttpContext context,
TelegramAuthService authService,
ISessionStore sessionStore,
TelegramLoginPayload request) =>
{
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
@@ -187,6 +191,8 @@ app.MapPost("/auth/telegram-login", async (
return Results.Unauthorized();
}
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
@@ -90,6 +90,52 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
}
public Task<MasterProfileSettings?> GetMasterProfileSettingsForCurrentUserAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return Task.FromResult<MasterProfileSettings?>(null);
return sessionStore.GetMasterProfileSettingsAsync(identity.Value.Platform, identity.Value.ExternalUserId);
}
public async Task UpdateMasterProfileSettingsForCurrentUserAsync(
string? publicSlug,
bool isPublic,
string displayName,
string? bio)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var normalizedDisplayName = displayName.Trim();
if (normalizedDisplayName.Length is < 2 or > 120)
{
throw new InvalidOperationException("Имя профиля должно быть от 2 до 120 символов.");
}
var normalizedBio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim();
if (normalizedBio?.Length > 1200)
{
throw new InvalidOperationException("Описание профиля должно быть не длиннее 1200 символов.");
}
var normalizedSlug = NormalizeMasterProfileSlug(publicSlug);
if (isPublic && normalizedSlug is null)
{
throw new InvalidOperationException("Для публичного профиля нужен короткий адрес.");
}
await sessionStore.UpdateMasterProfileSettingsAsync(
identity.Value.Platform,
identity.Value.ExternalUserId,
normalizedSlug,
isPublic,
normalizedDisplayName,
normalizedBio);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
{
var identity = GetCurrentIdentity();
@@ -472,4 +518,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
return slug;
}
private static string? NormalizeMasterProfileSlug(string? publicSlug) => NormalizePublicSlug(publicSlug);
}
+27 -1
View File
@@ -42,12 +42,35 @@ public sealed record WebPublicSession(
string Status,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount);
int WaitlistedPlayerCount,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
string Slug,
IReadOnlyList<WebPublicSession> Sessions,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record MasterProfileSettings(
Guid PlayerId,
string DisplayName,
string? PublicSlug,
bool IsPublic,
string? Bio);
public sealed record PublicMasterClub(
Guid GroupId,
string Name,
string Slug);
public sealed record PublicMasterProfile(
string Slug,
string DisplayName,
string? Bio,
IReadOnlyList<PublicMasterClub> Clubs,
IReadOnlyList<WebPublicSession> Sessions);
public interface ISessionStore
@@ -85,6 +108,9 @@ public interface ISessionStore
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
+273 -15
View File
@@ -110,7 +110,12 @@ internal sealed record WebBatchSessionRow(
bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
internal sealed record WebPublicGroupRow(
Guid GroupId,
string Name,
string Slug,
string? MasterProfileSlug,
string? MasterDisplayName);
internal sealed record ShowcaseSessionRow(
Guid Id,
Guid GroupId,
@@ -128,7 +133,10 @@ internal sealed record ShowcaseSessionRow(
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description);
string? Description,
string? MasterProfileSlug,
string? MasterDisplayName);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -303,7 +311,9 @@ public sealed class SessionService(
"""
SELECT g.id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.public_slug AS Slug
g.public_slug AS Slug,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM game_groups g
LEFT JOIN LATERAL (
SELECT s.title
@@ -312,11 +322,23 @@ public sealed class SessionService(
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND lower(g.public_slug) = lower(@Slug)
""",
new { Slug = slug });
new { Slug = slug, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
if (group is null)
{
@@ -324,7 +346,7 @@ public sealed class SessionService(
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
}
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
@@ -341,7 +363,9 @@ public sealed class SessionService(
s.status AS Status,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -365,6 +389,18 @@ public sealed class SessionService(
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
@@ -377,7 +413,8 @@ public sealed class SessionService(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
}
@@ -402,7 +439,9 @@ public sealed class SessionService(
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description
s.description AS Description,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -426,6 +465,18 @@ public sealed class SessionService(
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
@@ -459,14 +510,15 @@ public sealed class SessionService(
filter.IsOneShot,
filter.Format,
PageSize = pageSize,
Offset = (page - 1) * pageSize
Offset = (page - 1) * pageSize,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
return rows.Select(r => new ShowcaseSessionDto(
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
r.Description)).ToList();
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
}
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
@@ -490,7 +542,9 @@ public sealed class SessionService(
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description
s.description AS Description,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -514,6 +568,18 @@ public sealed class SessionService(
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
@@ -526,7 +592,8 @@ public sealed class SessionService(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
if (row is null)
@@ -536,7 +603,7 @@ public sealed class SessionService(
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
row.Description);
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
}
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
@@ -1822,6 +1889,182 @@ public sealed class SessionService(
}
}
public async Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
return null;
return await conn.QuerySingleOrDefaultAsync<MasterProfileSettings>(
"""
SELECT p.id AS PlayerId,
COALESCE(mp.display_name, p.display_name) AS DisplayName,
mp.public_slug AS PublicSlug,
COALESCE(mp.is_public, false) AS IsPublic,
mp.bio AS Bio
FROM players p
LEFT JOIN master_profiles mp ON mp.player_id = p.id
WHERE p.id = @PlayerId
""",
new { PlayerId = effectiveId.Value });
}
public async Task UpdateMasterProfileSettingsAsync(
string platform,
string externalUserId,
string? publicSlug,
bool isPublic,
string displayName,
string? bio)
{
await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
throw new InvalidOperationException("Current player not found.");
try
{
await conn.ExecuteAsync(
"""
INSERT INTO master_profiles (player_id, public_slug, is_public, display_name, bio)
VALUES (@PlayerId, @PublicSlug, @IsPublic, @DisplayName, @Bio)
ON CONFLICT (player_id) DO UPDATE
SET public_slug = EXCLUDED.public_slug,
is_public = EXCLUDED.is_public,
display_name = EXCLUDED.display_name,
bio = EXCLUDED.bio,
updated_at = now()
""",
new
{
PlayerId = effectiveId.Value,
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
IsPublic = isPublic,
DisplayName = displayName,
Bio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim()
});
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
{
throw new InvalidOperationException("Master profile slug is already in use.", ex);
}
}
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
{
await using var conn = await dataSource.OpenConnectionAsync();
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
"""
SELECT mp.player_id AS PlayerId,
mp.public_slug AS Slug,
mp.display_name AS DisplayName,
mp.bio AS Bio
FROM master_profiles mp
WHERE mp.is_public = true
AND mp.public_slug IS NOT NULL
AND lower(mp.public_slug) = lower(@Slug)
""",
new { Slug = slug });
if (profile is null)
return null;
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
}
private static async Task<List<PublicMasterClub>> GetPublicClubsForMasterAsync(
NpgsqlConnection conn,
Guid playerId)
{
return (await conn.QueryAsync<PublicMasterClub>(
"""
SELECT DISTINCT g.id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.public_slug AS Slug
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
WHERE COALESCE(manager_link.primary_player_id, gm.player_id) = @PlayerId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
ORDER BY Name
""",
new { PlayerId = playerId })).ToList();
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
NpgsqlConnection conn,
Guid playerId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
SELECT DISTINCT s.id AS Id,
s.group_id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
g.public_slug AS GroupSlug,
s.title AS Title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
JOIN group_managers gm ON gm.group_id = g.id
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
JOIN master_profiles mp ON mp.player_id = COALESCE(manager_link.primary_player_id, gm.player_id)
AND mp.player_id = @PlayerId
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
LEFT JOIN LATERAL (
SELECT recent.title
FROM sessions recent
WHERE recent.group_id = g.id
ORDER BY recent.scheduled_at DESC
LIMIT 1
) latest_session 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 = @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 g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
ORDER BY s.scheduled_at
""",
new
{
PlayerId = playerId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
})).ToList();
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
NpgsqlConnection conn,
Guid groupId)
@@ -1837,7 +2080,9 @@ public sealed class SessionService(
s.status AS Status,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -1861,6 +2106,18 @@ public sealed class SessionService(
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE s.group_id = @GroupId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
@@ -1874,7 +2131,8 @@ public sealed class SessionService(
GroupId = groupId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
})).ToList();
}
+52
View File
@@ -841,6 +841,58 @@ select option {
overflow-wrap: anywhere;
}
.profile-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.profile-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.master-profile-bio {
min-height: 7rem;
resize: vertical;
}
.master-profile-section {
margin-bottom: 1rem;
}
.master-profile-section h2 {
margin: 0 0 0.75rem;
font-family: 'Cinzel', serif;
}
.master-profile-club-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.master-profile-club-list a {
text-decoration: none;
}
.public-master-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
font-family: 'Jura', sans-serif;
}
.public-master-link a {
color: var(--accent-primary);
font-weight: 600;
text-decoration: none;
}
/* === Campaign templates === */
.campaign-template-panel {
margin-bottom: 1.5rem;
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Xml.Linq;
namespace GmRelay.Bot.Tests.Discord;
@@ -61,8 +62,9 @@ public sealed class DiscordProjectStructureTests
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
var version = GetProjectVersion(repoRoot);
Assert.Contains("gmrelay-discord-bot:3.4.0", compose);
Assert.Contains($"gmrelay-discord-bot:{version}", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,14 +77,15 @@ public sealed class DiscordProjectStructureTests
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
{
var repoRoot = GetRepoRoot();
var version = GetProjectVersion(repoRoot);
Assert.Contains("<Version>3.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 3.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains($"<Version>{version}</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains($"VERSION: {version}", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains($"gmrelay-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains($"gmrelay-web:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains($"gmrelay-discord-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v3.4.0",
$"v{version}",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
@@ -121,4 +124,13 @@ public sealed class DiscordProjectStructureTests
Assert.Contains("test:", discordBlock);
Assert.Contains("localhost:8082/health", discordBlock);
}
private static string GetProjectVersion(string repoRoot)
{
var props = XDocument.Load(Path.Combine(repoRoot, "Directory.Build.props"));
return props.Root?
.Element("PropertyGroup")?
.Element("Version")?
.Value ?? throw new InvalidOperationException("Version not found.");
}
}
@@ -828,6 +828,14 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastUpdatedPublicGroupId { get; private set; }
public string? LastUpdatedPublicSlug { get; private set; }
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
public MasterProfileSettings? MasterProfileSettings { get; set; } = new(Guid.NewGuid(), "Owner GM", null, false, null);
public bool UpdateMasterProfileCalled { get; private set; }
public string? LastMasterProfilePlatform { get; private set; }
public string? LastMasterProfileExternalUserId { get; private set; }
public string? LastMasterProfileSlug { get; private set; }
public bool? LastMasterProfileIsPublic { get; private set; }
public string? LastMasterProfileDisplayName { get; private set; }
public string? LastMasterProfileBio { get; private set; }
public Guid? LastPublicSessionId { get; private set; }
public Guid? LastPublicSessionGroupId { get; private set; }
public bool? LastSessionPublicValue { get; private set; }
@@ -1195,6 +1203,25 @@ public sealed class AuthorizedSessionServiceTests
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) =>
Task.FromResult(MasterProfileSettings);
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio)
{
UpdateMasterProfileCalled = true;
LastMasterProfilePlatform = platform;
LastMasterProfileExternalUserId = externalUserId;
LastMasterProfileSlug = publicSlug;
LastMasterProfileIsPublic = isPublic;
LastMasterProfileDisplayName = displayName;
LastMasterProfileBio = bio;
MasterProfileSettings = new(Guid.NewGuid(), displayName, publicSlug, isPublic, bio);
return Task.CompletedTask;
}
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) =>
Task.FromResult<PublicMasterProfile?>(null);
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(Guid.NewGuid());
@@ -0,0 +1,188 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class MasterProfilesTests
{
[Fact]
public async Task MigrationV028_ShouldAddMasterProfilesWithoutExternalIdentifiers()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql");
Assert.Contains("CREATE TABLE master_profiles", migration, StringComparison.Ordinal);
Assert.Contains("player_id", migration, StringComparison.Ordinal);
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
Assert.Contains("is_public", migration, StringComparison.Ordinal);
Assert.Contains("display_name", migration, StringComparison.Ordinal);
Assert.Contains("bio", migration, StringComparison.Ordinal);
Assert.Contains("ux_master_profiles_public_slug", migration, StringComparison.Ordinal);
Assert.DoesNotContain("external_user_id", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("telegram_id", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("discord", migration, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SessionStore_ShouldExposeSanitizedMasterProfileContracts()
{
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
Assert.Contains("MasterProfileSettings", sessionStore, StringComparison.Ordinal);
Assert.Contains("PublicMasterProfile", sessionStore, StringComparison.Ordinal);
Assert.Contains("PublicMasterClub", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("UpdateMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPublicMasterProfileBySlugAsync", sessionStore, StringComparison.Ordinal);
var publicProfileSection = RecordSection(sessionStore, "PublicMasterProfile");
Assert.DoesNotContain("AvatarUrl", publicProfileSection, StringComparison.Ordinal);
Assert.DoesNotContain("ExternalUserId", publicProfileSection, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramId", publicProfileSection, StringComparison.Ordinal);
Assert.DoesNotContain("DiscordId", publicProfileSection, StringComparison.Ordinal);
}
[Fact]
public async Task PublicMasterProfilePage_ShouldBePublicAndHideTechnicalIdentityData()
{
var publicProfilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor");
Assert.Contains("@page \"/gm/{Slug}\"", publicProfilePage, StringComparison.Ordinal);
Assert.Contains("@layout PublicLayout", publicProfilePage, StringComparison.Ordinal);
Assert.Contains("GetPublicMasterProfileBySlugAsync", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("@attribute [Authorize]", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("ExternalUserId", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramId", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("DiscordId", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("AvatarUrl", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("LinkedIdentity", publicProfilePage, StringComparison.Ordinal);
}
[Fact]
public async Task ProfilePage_ShouldManageMasterProfilePublication()
{
var profilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Profile.razor");
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
Assert.Contains("GetMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
Assert.Contains("UpdateMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
Assert.Contains("masterProfileModel", profilePage, StringComparison.Ordinal);
Assert.Contains("PublicMasterProfileUrl", profilePage, StringComparison.Ordinal);
Assert.Contains("NormalizeMasterProfileSlug", authorizedService, StringComparison.Ordinal);
}
[Fact]
public async Task TelegramLoginEndpoints_ShouldUpsertPlayersForProfileManagement()
{
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
}
[Fact]
public async Task PublicGamePages_ShouldLinkPublishedMasterProfilesWithoutPrivateIds()
{
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
var showcasePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Showcase.razor");
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("MasterProfileSlug", publicClubPage, StringComparison.Ordinal);
Assert.Contains("MasterProfileSlug", publicSessionPage, StringComparison.Ordinal);
Assert.Contains("MasterProfileSlug", showcasePage, StringComparison.Ordinal);
Assert.Contains("master_profiles", sessionService, StringComparison.Ordinal);
Assert.DoesNotContain("targetExternalUserId", PublicQuerySection(sessionService), StringComparison.Ordinal);
Assert.DoesNotContain("target_platform", PublicQuerySection(sessionService), StringComparison.Ordinal);
}
[Fact]
public async Task PublicMasterProfileQueries_ShouldIncludeCoGmManagedPublishedGames()
{
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
var clubsQuery = MethodSection(sessionService, "GetPublicClubsForMasterAsync");
var sessionsQuery = MethodSection(sessionService, "GetPublicSessionsForMasterAsync");
Assert.Contains("JOIN group_managers gm", clubsQuery, StringComparison.Ordinal);
Assert.Contains("JOIN group_managers gm", sessionsQuery, StringComparison.Ordinal);
Assert.DoesNotContain("gm.role = @OwnerRole", clubsQuery, StringComparison.Ordinal);
Assert.DoesNotContain("gm.role = @OwnerRole", sessionsQuery, StringComparison.Ordinal);
}
private static string RecordSection(string source, string recordName)
{
var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf(");", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..(end + 2)];
}
private static string PublicQuerySection(string source)
{
var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..end];
}
private static string MethodSection(string source, string methodName)
{
var start = -1;
var searchFrom = 0;
while (searchFrom < source.Length)
{
var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal);
if (candidate < 0)
return string.Empty;
var lineStart = source.LastIndexOf('\n', candidate);
var headerStart = lineStart < 0 ? 0 : lineStart + 1;
var header = source[headerStart..candidate];
if (header.Contains("private static async Task", StringComparison.Ordinal))
{
start = candidate;
break;
}
searchFrom = candidate + methodName.Length;
}
var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal);
if (nextMethod < 0)
{
nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal);
}
return nextMethod < 0 ? source[start..] : source[start..nextMethod];
}
private static string EndpointSection(string source, string route)
{
var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal);
return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint];
}
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}'.");
}
}