feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
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:
@@ -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" => "Онлайн",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user