Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c1d3abd7e | |||
| d81564c308 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.4.0
|
||||
VERSION: 3.5.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.4.0</Version>
|
||||
<Version>3.5.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v3.4.0`.
|
||||
**Текущая версия:** `v3.5.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
|
||||
+3
-3
@@ -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:
|
||||
|
||||
@@ -8,19 +8,19 @@ C4Context
|
||||
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules without private player data")
|
||||
Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile pages, and shared scheduling logic")
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles")
|
||||
|
||||
Rel(gm, telegram, "Creates and manages sessions")
|
||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||
Rel(player, telegram, "Uses inline buttons")
|
||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||
Rel(visitor, gmrelay, "Views public club and session pages")
|
||||
Rel(visitor, gmrelay, "Views public club, session, and GM profile pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -41,9 +41,9 @@ C4Container
|
||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile pages, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
@@ -53,7 +53,7 @@ C4Container
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule pages")
|
||||
Rel(visitor, web, "Read-only public schedule and sanitized GM profile pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Public GM profiles for catalog and club trust pages.
|
||||
|
||||
CREATE TABLE master_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||
public_slug VARCHAR(120),
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
bio TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ux_master_profiles_public_slug
|
||||
ON master_profiles (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_master_profiles_public
|
||||
ON master_profiles (lower(public_slug))
|
||||
WHERE is_public = true AND public_slug IS NOT NULL;
|
||||
@@ -17,4 +17,6 @@ public sealed record ShowcaseSessionDto(
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration,
|
||||
string? Description);
|
||||
string? Description,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.4.0</div>
|
||||
<div class="nav-version">v3.5.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@attribute [Authorize]
|
||||
@inject ISessionStore SessionStore
|
||||
@inject AuthorizedSessionService AuthorizedSessionService
|
||||
@inject IConfiguration Configuration
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -12,6 +13,65 @@
|
||||
<div class="profile-container">
|
||||
<h1 class="page-title">Профиль</h1>
|
||||
|
||||
@if (masterProfile is not null)
|
||||
{
|
||||
<div class="profile-card master-profile-card">
|
||||
<div class="profile-card-header">
|
||||
<div>
|
||||
<h2 class="section-title">Публичный профиль мастера</h2>
|
||||
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||
</div>
|
||||
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
|
||||
<div class="gm-form-group public-toggle-field">
|
||||
<label class="gm-checkbox-label">
|
||||
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
|
||||
<span>Опубликовать профиль</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="profile-form-grid">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя в публичном профиле</label>
|
||||
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Описание</label>
|
||||
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
|
||||
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
|
||||
</button>
|
||||
@if (PublicMasterProfileUrl is not null)
|
||||
{
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||
Открыть публичный профиль
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (PublicMasterProfileUrl is not null)
|
||||
{
|
||||
<div class="public-link-row">
|
||||
<span>Ссылка профиля</span>
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<p class="loading-text">Загрузка...</p>
|
||||
@@ -92,11 +152,14 @@
|
||||
|
||||
@code {
|
||||
private List<LinkedIdentity>? identities;
|
||||
private MasterProfileSettings? masterProfile;
|
||||
private string? currentPlatform;
|
||||
private string? currentExternalUserId;
|
||||
private bool isUnlinking;
|
||||
private bool savingMasterProfile;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private MasterProfileEditModel masterProfileModel = new();
|
||||
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
@@ -131,6 +194,7 @@
|
||||
}
|
||||
|
||||
await LoadIdentities();
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
|
||||
private async Task LoadIdentities()
|
||||
@@ -152,6 +216,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMasterProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
|
||||
if (masterProfile is not null)
|
||||
{
|
||||
masterProfileModel = new MasterProfileEditModel
|
||||
{
|
||||
DisplayName = masterProfile.DisplayName,
|
||||
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
|
||||
IsPublic = masterProfile.IsPublic,
|
||||
Bio = masterProfile.Bio ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private string? PublicMasterProfileUrl =>
|
||||
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
|
||||
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
|
||||
: null;
|
||||
|
||||
private async Task SaveMasterProfile()
|
||||
{
|
||||
savingMasterProfile = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
|
||||
masterProfileModel.PublicSlug,
|
||||
masterProfileModel.IsPublic,
|
||||
masterProfileModel.DisplayName,
|
||||
masterProfileModel.Bio);
|
||||
|
||||
successMessage = "Публичный профиль мастера обновлён.";
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingMasterProfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasLinkedPlatform(string platform)
|
||||
{
|
||||
return identities?.Any(i => i.Platform == platform) ?? false;
|
||||
@@ -188,4 +306,12 @@
|
||||
isUnlinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MasterProfileEditModel
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string PublicSlug { get; set; } = string.Empty;
|
||||
public bool IsPublic { get; set; }
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ else if (club is not null)
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
|
||||
{
|
||||
<div class="public-share-row">
|
||||
<span>Мастер</span>
|
||||
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
|
||||
@(club.MasterDisplayName ?? "Профиль мастера")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (club.Sessions.Count == 0)
|
||||
@@ -92,6 +101,8 @@ else if (club is not null)
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
@page "/gm/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && profile is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Профиль мастера не найден</h1>
|
||||
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (profile is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact master-profile-hero">
|
||||
<span class="status-badge status-success">Мастер</span>
|
||||
<h1>@profile.DisplayName</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(profile.Bio))
|
||||
{
|
||||
<p>@profile.Bio</p>
|
||||
}
|
||||
<div class="public-share-row">
|
||||
<span>Ссылка профиля</span>
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (profile.Clubs.Count > 0)
|
||||
{
|
||||
<section class="glass-card master-profile-section">
|
||||
<h2>Клубы</h2>
|
||||
<div class="master-profile-club-list">
|
||||
@foreach (var club in profile.Clubs)
|
||||
{
|
||||
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (profile.Sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Опубликованных игр пока нет</h2>
|
||||
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in profile.Sessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.GroupName</span>
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private GmRelay.Web.Services.PublicMasterProfile? profile;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
|
||||
|
||||
private string PublicMasterProfileUrl =>
|
||||
profile is null
|
||||
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
|
||||
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
profile = string.IsNullOrWhiteSpace(Slug)
|
||||
? null
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
SessionStatus.Planned => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,13 @@ else if (session is not null)
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h1>@session.Title</h1>
|
||||
<p>@session.GroupName</p>
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Мастер</span>
|
||||
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||
</div>
|
||||
}
|
||||
<div class="session-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(session.System))
|
||||
{
|
||||
@@ -101,6 +108,10 @@ else if (session is not null)
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<a class="btn-gm btn-gm-outline" href="@MasterProfilePath(session.MasterProfileSlug)">Мастер</a>
|
||||
}
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||
@if (session.AllowDirectRegistration)
|
||||
{
|
||||
@@ -129,6 +140,8 @@ else if (session is not null)
|
||||
|
||||
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
|
||||
@@ -130,6 +130,12 @@ else
|
||||
<div class="showcase-card-club">
|
||||
<span>@session.GroupName</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<div class="showcase-card-master">
|
||||
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||
</div>
|
||||
}
|
||||
<div class="showcase-card-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
|
||||
@if (session.AllowDirectRegistration)
|
||||
@@ -254,7 +260,8 @@ else
|
||||
|
||||
.showcase-card-meta,
|
||||
.showcase-card-seats,
|
||||
.showcase-card-club {
|
||||
.showcase-card-club,
|
||||
.showcase-card-master {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
@@ -264,6 +271,12 @@ else
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.showcase-card-master a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.showcase-card-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 0.75rem;
|
||||
@@ -428,6 +441,8 @@ else
|
||||
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
|
||||
}
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
@@ -61,8 +62,9 @@ public sealed class DiscordProjectStructureTests
|
||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
var version = GetProjectVersion(repoRoot);
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:3.4.0", compose);
|
||||
Assert.Contains($"gmrelay-discord-bot:{version}", compose);
|
||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
@@ -75,14 +77,15 @@ public sealed class DiscordProjectStructureTests
|
||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var version = GetProjectVersion(repoRoot);
|
||||
|
||||
Assert.Contains("<Version>3.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"<Version>{version}</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains($"VERSION: {version}", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains($"gmrelay-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"gmrelay-web:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"gmrelay-discord-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v3.4.0",
|
||||
$"v{version}",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
@@ -121,4 +124,13 @@ public sealed class DiscordProjectStructureTests
|
||||
Assert.Contains("test:", discordBlock);
|
||||
Assert.Contains("localhost:8082/health", discordBlock);
|
||||
}
|
||||
|
||||
private static string GetProjectVersion(string repoRoot)
|
||||
{
|
||||
var props = XDocument.Load(Path.Combine(repoRoot, "Directory.Build.props"));
|
||||
return props.Root?
|
||||
.Element("PropertyGroup")?
|
||||
.Element("Version")?
|
||||
.Value ?? throw new InvalidOperationException("Version not found.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,6 +828,14 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Guid? LastUpdatedPublicGroupId { get; private set; }
|
||||
public string? LastUpdatedPublicSlug { get; private set; }
|
||||
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
|
||||
public MasterProfileSettings? MasterProfileSettings { get; set; } = new(Guid.NewGuid(), "Owner GM", null, false, null);
|
||||
public bool UpdateMasterProfileCalled { get; private set; }
|
||||
public string? LastMasterProfilePlatform { get; private set; }
|
||||
public string? LastMasterProfileExternalUserId { get; private set; }
|
||||
public string? LastMasterProfileSlug { get; private set; }
|
||||
public bool? LastMasterProfileIsPublic { get; private set; }
|
||||
public string? LastMasterProfileDisplayName { get; private set; }
|
||||
public string? LastMasterProfileBio { get; private set; }
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
@@ -1195,6 +1203,25 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult(MasterProfileSettings);
|
||||
|
||||
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio)
|
||||
{
|
||||
UpdateMasterProfileCalled = true;
|
||||
LastMasterProfilePlatform = platform;
|
||||
LastMasterProfileExternalUserId = externalUserId;
|
||||
LastMasterProfileSlug = publicSlug;
|
||||
LastMasterProfileIsPublic = isPublic;
|
||||
LastMasterProfileDisplayName = displayName;
|
||||
LastMasterProfileBio = bio;
|
||||
MasterProfileSettings = new(Guid.NewGuid(), displayName, publicSlug, isPublic, bio);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) =>
|
||||
Task.FromResult<PublicMasterProfile?>(null);
|
||||
|
||||
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult<Guid?>(Guid.NewGuid());
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class MasterProfilesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationV028_ShouldAddMasterProfilesWithoutExternalIdentifiers()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql");
|
||||
|
||||
Assert.Contains("CREATE TABLE master_profiles", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("player_id", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("is_public", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("display_name", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("bio", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_master_profiles_public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("external_user_id", migration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("telegram_id", migration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("discord", migration, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStore_ShouldExposeSanitizedMasterProfileContracts()
|
||||
{
|
||||
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
||||
|
||||
Assert.Contains("MasterProfileSettings", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicMasterProfile", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicMasterClub", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("UpdateMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicMasterProfileBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||
|
||||
var publicProfileSection = RecordSection(sessionStore, "PublicMasterProfile");
|
||||
Assert.DoesNotContain("AvatarUrl", publicProfileSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ExternalUserId", publicProfileSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramId", publicProfileSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DiscordId", publicProfileSection, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicMasterProfilePage_ShouldBePublicAndHideTechnicalIdentityData()
|
||||
{
|
||||
var publicProfilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor");
|
||||
|
||||
Assert.Contains("@page \"/gm/{Slug}\"", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicMasterProfileBySlugAsync", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ExternalUserId", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramId", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DiscordId", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("AvatarUrl", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("LinkedIdentity", publicProfilePage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProfilePage_ShouldManageMasterProfilePublication()
|
||||
{
|
||||
var profilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Profile.razor");
|
||||
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||
|
||||
Assert.Contains("GetMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("UpdateMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("masterProfileModel", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicMasterProfileUrl", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("NormalizeMasterProfileSlug", authorizedService, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TelegramLoginEndpoints_ShouldUpsertPlayersForProfileManagement()
|
||||
{
|
||||
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
|
||||
|
||||
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
|
||||
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
|
||||
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
|
||||
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicGamePages_ShouldLinkPublishedMasterProfilesWithoutPrivateIds()
|
||||
{
|
||||
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
|
||||
var showcasePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Showcase.razor");
|
||||
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("MasterProfileSlug", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("MasterProfileSlug", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.Contains("MasterProfileSlug", showcasePage, StringComparison.Ordinal);
|
||||
Assert.Contains("master_profiles", sessionService, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("targetExternalUserId", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("target_platform", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicMasterProfileQueries_ShouldIncludeCoGmManagedPublishedGames()
|
||||
{
|
||||
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
var clubsQuery = MethodSection(sessionService, "GetPublicClubsForMasterAsync");
|
||||
var sessionsQuery = MethodSection(sessionService, "GetPublicSessionsForMasterAsync");
|
||||
|
||||
Assert.Contains("JOIN group_managers gm", clubsQuery, StringComparison.Ordinal);
|
||||
Assert.Contains("JOIN group_managers gm", sessionsQuery, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gm.role = @OwnerRole", clubsQuery, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gm.role = @OwnerRole", sessionsQuery, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string RecordSection(string source, string recordName)
|
||||
{
|
||||
var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf(");", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..(end + 2)];
|
||||
}
|
||||
|
||||
private static string PublicQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..end];
|
||||
}
|
||||
|
||||
private static string MethodSection(string source, string methodName)
|
||||
{
|
||||
var start = -1;
|
||||
var searchFrom = 0;
|
||||
while (searchFrom < source.Length)
|
||||
{
|
||||
var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal);
|
||||
if (candidate < 0)
|
||||
return string.Empty;
|
||||
|
||||
var lineStart = source.LastIndexOf('\n', candidate);
|
||||
var headerStart = lineStart < 0 ? 0 : lineStart + 1;
|
||||
var header = source[headerStart..candidate];
|
||||
if (header.Contains("private static async Task", StringComparison.Ordinal))
|
||||
{
|
||||
start = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
searchFrom = candidate + methodName.Length;
|
||||
}
|
||||
|
||||
var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal);
|
||||
if (nextMethod < 0)
|
||||
{
|
||||
nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return nextMethod < 0 ? source[start..] : source[start..nextMethod];
|
||||
}
|
||||
|
||||
private static string EndpointSection(string source, string route)
|
||||
{
|
||||
var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal);
|
||||
return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint];
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user