From 0c1d3abd7e63cd6d9134d5de2f490d21a378a0a3 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Fri, 29 May 2026 00:08:14 +0300 Subject: [PATCH] feat(web): add public master profiles Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces. Bump version -> 3.5.0 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 3 +- compose.yaml | 6 +- docs/c4-system-context.md | 14 +- .../Migrations/V028__add_master_profiles.sql | 20 ++ .../Features/Showcase/ShowcaseSessionDto.cs | 4 +- .../Components/Layout/NavMenu.razor | 2 +- .../Components/Pages/Profile.razor | 126 ++++++++ .../Components/Pages/PublicClub.razor | 11 + .../Pages/PublicMasterProfile.razor | 136 +++++++++ .../Components/Pages/PublicSession.razor | 13 + .../Components/Pages/Showcase.razor | 17 +- src/GmRelay.Web/Program.cs | 6 + .../Services/AuthorizedSessionService.cs | 48 +++ src/GmRelay.Web/Services/ISessionStore.cs | 28 +- src/GmRelay.Web/Services/SessionService.cs | 288 +++++++++++++++++- src/GmRelay.Web/wwwroot/app.css | 52 ++++ .../Discord/DiscordProjectStructureTests.cs | 26 +- .../Web/AuthorizedSessionServiceTests.cs | 27 ++ .../Web/MasterProfilesTests.cs | 188 ++++++++++++ 21 files changed, 980 insertions(+), 39 deletions(-) create mode 100644 src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql create mode 100644 src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor create mode 100644 tests/GmRelay.Bot.Tests/Web/MasterProfilesTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a4fffc1..de9e7b8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.4.0 + VERSION: 3.5.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 0e66213..937db6b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.4.0 + 3.5.0 net10.0 preview enable diff --git a/README.md b/README.md index 25761c5..5be80c3 100644 --- a/README.md +++ b/README.md @@ -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` у всей пачки; diff --git a/compose.yaml b/compose.yaml index e6a0f10..167d094 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/docs/c4-system-context.md b/docs/c4-system-context.md index 774c8f4..311a265 100644 --- a/docs/c4-system-context.md +++ b/docs/c4-system-context.md @@ -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") diff --git a/src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql b/src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql new file mode 100644 index 0000000..2d5aa1b --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql @@ -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; diff --git a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs index 4ff58c8..2844d82 100644 --- a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs +++ b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs @@ -17,4 +17,6 @@ public sealed record ShowcaseSessionDto( int ActivePlayerCount, int WaitlistedPlayerCount, bool AllowDirectRegistration, - string? Description); + string? Description, + string? MasterProfileSlug = null, + string? MasterDisplayName = null); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 830698b..09c4bba 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -73,7 +73,7 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/Profile.razor b/src/GmRelay.Web/Components/Pages/Profile.razor index beefed4..e77be65 100644 --- a/src/GmRelay.Web/Components/Pages/Profile.razor +++ b/src/GmRelay.Web/Components/Pages/Profile.razor @@ -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 @@

Профиль

+ @if (masterProfile is not null) + { +
+
+
+

Публичный профиль мастера

+

Показывается в каталоге, опубликованных играх и публичных страницах клуба.

+
+ @(masterProfile.IsPublic ? "Публичный" : "Скрыт") +
+ + +
+ +
+ +
+
+ + +
+
+ + +
Латиница, цифры и дефисы, например `night-city-gm`.
+
+
+ +
+ + +
+ +
+ + @if (PublicMasterProfileUrl is not null) + { + + Открыть публичный профиль + + } +
+
+ + @if (PublicMasterProfileUrl is not null) + { + + } +
+ } + @if (identities is null) {

Загрузка...

@@ -92,11 +152,14 @@ @code { private List? 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? 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; + } } diff --git a/src/GmRelay.Web/Components/Pages/PublicClub.razor b/src/GmRelay.Web/Components/Pages/PublicClub.razor index 22a4f21..ce511cb 100644 --- a/src/GmRelay.Web/Components/Pages/PublicClub.razor +++ b/src/GmRelay.Web/Components/Pages/PublicClub.razor @@ -38,6 +38,15 @@ else if (club is not null) Ссылка клуба @PublicClubUrl
+ @if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug)) + { + + } @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 diff --git a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor new file mode 100644 index 0000000..e8ada8a --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor @@ -0,0 +1,136 @@ +@page "/gm/{Slug}" +@layout PublicLayout +@inject ISessionStore SessionStore +@inject NavigationManager Navigation + +@PageTitleText + +@if (loaded && profile is null) +{ + + + + +
+ Недоступно +

Профиль мастера не найден

+

Мастер скрыл профиль или этот короткий адрес больше не используется.

+
+} +else if (!loaded) +{ +
+
+
+
+} +else if (profile is not null) +{ + + + + +
+ Мастер +

@profile.DisplayName

+ @if (!string.IsNullOrWhiteSpace(profile.Bio)) + { +

@profile.Bio

+ } + +
+ + @if (profile.Clubs.Count > 0) + { +
+

Клубы

+
+ @foreach (var club in profile.Clubs) + { + @club.Name + } +
+
+ } + + @if (profile.Sessions.Count == 0) + { +
+

Опубликованных игр пока нет

+

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

+
+ } + else + { +
+ @foreach (var session in profile.Sessions) + { +
+
+ @TranslateStatus(session.Status) +

@session.Title

+
+ @session.GroupName + @session.ScheduledAt.FormatMoscow() + @FormatSeats(session) +
+
+ Открыть +
+ } +
+ } +} + +@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 + }; +} diff --git a/src/GmRelay.Web/Components/Pages/PublicSession.razor b/src/GmRelay.Web/Components/Pages/PublicSession.razor index aa7c11d..c0efef9 100644 --- a/src/GmRelay.Web/Components/Pages/PublicSession.razor +++ b/src/GmRelay.Web/Components/Pages/PublicSession.razor @@ -42,6 +42,13 @@ else if (session is not null) @TranslateStatus(session.Status)

@session.Title

@session.GroupName

+ @if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug)) + { + + }
@if (!string.IsNullOrWhiteSpace(session.System)) { @@ -101,6 +108,10 @@ else if (session is not null) { Расписание клуба } + @if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug)) + { + Мастер + } Ссылка на сессию @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; diff --git a/src/GmRelay.Web/Components/Pages/Showcase.razor b/src/GmRelay.Web/Components/Pages/Showcase.razor index c33b485..b9210e7 100644 --- a/src/GmRelay.Web/Components/Pages/Showcase.razor +++ b/src/GmRelay.Web/Components/Pages/Showcase.razor @@ -130,6 +130,12 @@ else
@session.GroupName
+ @if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug)) + { + + }
Подробнее @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" => "Онлайн", diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 8d16f9d..f4308b0 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -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, diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 7da606f..f6d3e8a 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -90,6 +90,52 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled); } + public Task GetMasterProfileSettingsForCurrentUserAsync() + { + var identity = GetCurrentIdentity(); + if (identity is null) + return Task.FromResult(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); } diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index 79c9548..ec97f42 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -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 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 Clubs, IReadOnlyList 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> GetSessionHistoryAsync(Guid sessionId); Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl); + Task GetMasterProfileSettingsAsync(string platform, string externalUserId); + Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio); + Task GetPublicMasterProfileBySlugAsync(string slug); // --- Identity linking (issue #35) --- Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId); diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 3e5f849..1472c61 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -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 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 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 RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) @@ -1822,6 +1889,182 @@ public sealed class SessionService( } } + public async Task 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( + """ + 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 GetPublicMasterProfileBySlugAsync(string slug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var profile = await conn.QuerySingleOrDefaultAsync( + """ + 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> GetPublicClubsForMasterAsync( + NpgsqlConnection conn, + Guid playerId) + { + return (await conn.QueryAsync( + """ + 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> GetPublicSessionsForMasterAsync( + NpgsqlConnection conn, + Guid playerId) + { + return (await conn.QueryAsync( + """ + 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> 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(); } diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 2daf2d5..d571421 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -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; diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 0ee8503..8dad2f2 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -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("3.4.0", 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}", 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."); + } } diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 3675b8c..744e297 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -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 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 GetPublicMasterProfileBySlugAsync(string slug) => + Task.FromResult(null); + public Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId) => Task.FromResult(Guid.NewGuid()); diff --git a/tests/GmRelay.Bot.Tests/Web/MasterProfilesTests.cs b/tests/GmRelay.Bot.Tests/Web/MasterProfilesTests.cs new file mode 100644 index 0000000..403eabc --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/MasterProfilesTests.cs @@ -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 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}'."); + } +}