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
This commit is contained in:
2026-05-29 00:08:14 +03:00
parent d81564c308
commit 0c1d3abd7e
21 changed files with 980 additions and 39 deletions
@@ -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;