Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.2.0
|
||||
VERSION: 3.3.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.2.0</Version>
|
||||
<Version>3.3.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v2.8.0`.
|
||||
**Текущая версия:** `v3.3.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.2.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.2.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -8,8 +8,9 @@ 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")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club 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")
|
||||
@@ -19,6 +20,7 @@ C4Context
|
||||
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(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -34,13 +36,14 @@ C4Container
|
||||
|
||||
Person(gm, "Game Master")
|
||||
Person(player, "Player")
|
||||
Person(visitor, "Public visitor")
|
||||
|
||||
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, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club 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, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
@@ -50,6 +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(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Public club pages and read-only schedule publication controls.
|
||||
|
||||
ALTER TABLE game_groups
|
||||
ADD COLUMN public_slug VARCHAR(120),
|
||||
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE UNIQUE INDEX ux_game_groups_public_slug
|
||||
ON game_groups (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_sessions_public_schedule
|
||||
ON sessions (group_id, scheduled_at)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -75,7 +75,9 @@ public sealed class DiscordNewSessionHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.2.0</div>
|
||||
<div class="nav-version">v3.3.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="public-shell">
|
||||
<header class="public-topbar">
|
||||
<a class="public-brand" href="/">
|
||||
<img src="/logo.png" alt="GM-Relay" />
|
||||
<span>GM-Relay</span>
|
||||
</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
</header>
|
||||
|
||||
<main class="public-content">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
Произошла непредвиденная ошибка.
|
||||
<a href="." class="reload">Перезагрузить</a>
|
||||
<span class="dismiss">×</span>
|
||||
</div>
|
||||
@@ -40,8 +40,8 @@
|
||||
</span>
|
||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
|
||||
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
|
||||
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@
|
||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
||||
<label class="gm-form-label">@CoGmIdLabel</label>
|
||||
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя</label>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Username</label>
|
||||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
||||
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||
@@ -72,6 +72,58 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (publicSettings is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Публичная страница клуба</h3>
|
||||
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
|
||||
</div>
|
||||
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
|
||||
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group public-toggle-field">
|
||||
<label class="gm-checkbox-label">
|
||||
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
|
||||
<span>Включить публичное расписание</span>
|
||||
</label>
|
||||
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
|
||||
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
|
||||
</button>
|
||||
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||
{
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||
Открыть публичную страницу
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||
{
|
||||
<div class="public-link-row">
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -201,6 +253,17 @@
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<div class="batch-publish-row">
|
||||
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||
@FormatBatchPublication(batch)
|
||||
</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||
@(IsBatchPublishBusy(batch)
|
||||
? "Обновляем..."
|
||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="batch-clone-row">
|
||||
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||
<option value="week">Следующая неделя</option>
|
||||
@@ -249,6 +312,16 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="session-table-actions">
|
||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||
}
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@@ -337,6 +410,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||
}
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@@ -398,17 +480,23 @@
|
||||
private List<WebSession>? sessions;
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private WebPublicGroupSettings? publicSettings;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private Guid? processingTemplateId;
|
||||
private Guid? publishingBatchId;
|
||||
private Guid? publishingSessionId;
|
||||
private string? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private bool savingPublicSettings;
|
||||
private string? currentPlatform;
|
||||
private string? externalUserId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private CoGmEditModel coGmModel = new();
|
||||
private PublicSettingsEditModel publicSettingsModel = new();
|
||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||
private HashSet<Guid> expandedSessions = new();
|
||||
private Guid? kickingParticipantId;
|
||||
@@ -423,6 +511,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
currentPlatform = platform;
|
||||
await LoadSessions();
|
||||
}
|
||||
|
||||
@@ -442,6 +531,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
|
||||
if (publicSettings is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||
if (campaignTemplates is null)
|
||||
{
|
||||
@@ -451,6 +547,92 @@
|
||||
|
||||
RebuildBatchModels();
|
||||
RebuildCampaignTemplateModels();
|
||||
RebuildPublicSettingsModel();
|
||||
}
|
||||
|
||||
private async Task SavePublicSettings()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
savingPublicSettings = true;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
GroupId,
|
||||
publicSettingsModel.PublicSlug,
|
||||
publicSettingsModel.PublicScheduleEnabled);
|
||||
successMessage = "Настройки публичной страницы обновлены.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingPublicSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
publishingBatchId = batch.BatchId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Batch опубликован в публичном расписании."
|
||||
: "Batch скрыт из публичного расписания.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
publishingBatchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
publishingSessionId = sessionId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Сессия опубликована в публичном расписании."
|
||||
: "Сессия скрыта из публичного расписания.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
publishingSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddCoGm()
|
||||
@@ -458,9 +640,16 @@
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
|
||||
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
|
||||
if (coGmExternalUserId.Length == 0)
|
||||
{
|
||||
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
|
||||
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
|
||||
{
|
||||
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,10 +659,10 @@
|
||||
{
|
||||
await SessionService.AddCoGmForOwnerAsync(
|
||||
GroupId,
|
||||
"Telegram",
|
||||
coGmModel.TelegramId.Value.ToString(),
|
||||
CoGmPlatform,
|
||||
coGmExternalUserId,
|
||||
coGmModel.DisplayName,
|
||||
coGmModel.TelegramUsername);
|
||||
coGmModel.ExternalUsername);
|
||||
|
||||
coGmModel = new();
|
||||
successMessage = "Co-GM добавлен.";
|
||||
@@ -493,15 +682,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveCoGm(string coGmExternalUserId)
|
||||
private async Task RemoveCoGm(WebGroupManager manager)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
removingCoGmId = coGmExternalUserId;
|
||||
removingCoGmId = ManagerKey(manager);
|
||||
var platform = ManagerPlatform(manager);
|
||||
var coGmExternalUserId = ManagerExternalUserId(manager);
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
|
||||
successMessage = "Co-GM удалён.";
|
||||
await LoadSessions();
|
||||
}
|
||||
@@ -795,7 +986,9 @@
|
||||
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||
IntervalDays = InferIntervalDays(orderedSessions),
|
||||
SessionCount = orderedSessions.Count
|
||||
SessionCount = orderedSessions.Count,
|
||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||
};
|
||||
})
|
||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||
@@ -823,6 +1016,20 @@
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
private void RebuildPublicSettingsModel()
|
||||
{
|
||||
if (publicSettings is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
publicSettingsModel = new PublicSettingsEditModel
|
||||
{
|
||||
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
|
||||
PublicSlug = publicSettings.PublicSlug ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
||||
{
|
||||
batch.Title = batch.Title.Trim();
|
||||
@@ -832,24 +1039,76 @@
|
||||
|
||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||||
|
||||
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
|
||||
|
||||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
||||
|
||||
private string? PublicClubUrl =>
|
||||
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
|
||||
? null
|
||||
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
|
||||
|
||||
private string PublicSessionUrl(Guid sessionId) =>
|
||||
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
|
||||
|
||||
private static string FormatPublicationStatus(WebSession session) =>
|
||||
session.IsPublic ? "Опубликована" : "Скрыта";
|
||||
|
||||
private static string GetPublicationStatusClass(WebSession session) =>
|
||||
session.IsPublic ? "status-success" : "status-neutral";
|
||||
|
||||
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
|
||||
batch.PublicSessionCount == 0
|
||||
? "Все игры скрыты"
|
||||
: batch.PublicSessionCount == batch.SessionCount
|
||||
? "Все игры опубликованы"
|
||||
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
|
||||
|
||||
private string CoGmPlatform =>
|
||||
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
|
||||
? "Telegram"
|
||||
: groupManagement.Group.Platform;
|
||||
|
||||
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
|
||||
|
||||
private string CurrentUserRole =>
|
||||
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
|
||||
groupManagement?.Managers.FirstOrDefault(manager =>
|
||||
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
|
||||
ManagerExternalUserId(manager) == externalUserId)?.Role
|
||||
?? GroupManagerRoleExtensions.CoGmValue;
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
|
||||
private static string FormatManager(WebGroupManager manager)
|
||||
private string FormatManager(WebGroupManager manager)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "@" + manager.TelegramUsername;
|
||||
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
|
||||
? manager.TelegramUsername
|
||||
: manager.ExternalUsername;
|
||||
var identity = string.IsNullOrWhiteSpace(username)
|
||||
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
|
||||
: "@" + username.TrimStart('@');
|
||||
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
|
||||
}
|
||||
|
||||
private string ManagerPlatform(WebGroupManager manager) =>
|
||||
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
|
||||
|
||||
private static string ManagerExternalUserId(WebGroupManager manager) =>
|
||||
string.IsNullOrWhiteSpace(manager.ExternalUserId)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: manager.ExternalUserId;
|
||||
|
||||
private string ManagerKey(WebGroupManager manager) =>
|
||||
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
|
||||
|
||||
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
|
||||
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
|
||||
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
|
||||
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
|
||||
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
|
||||
|
||||
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||
{
|
||||
if (orderedSessions.Count < 2)
|
||||
@@ -926,6 +1185,8 @@
|
||||
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||
public int IntervalDays { get; set; } = 7;
|
||||
public int SessionCount { get; init; }
|
||||
public int PublicSessionCount { get; init; }
|
||||
public bool AllSessionsPublic { get; init; }
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
@@ -944,8 +1205,14 @@
|
||||
|
||||
private sealed class CoGmEditModel
|
||||
{
|
||||
public long? TelegramId { get; set; }
|
||||
public string ExternalUserId { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string? TelegramUsername { get; set; }
|
||||
public string? ExternalUsername { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PublicSettingsEditModel
|
||||
{
|
||||
public bool PublicScheduleEnabled { get; set; }
|
||||
public string? PublicSlug { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
@page "/club/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && club 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 (club is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero">
|
||||
<span class="status-badge status-success">Публичное расписание</span>
|
||||
<h1>@club.Name</h1>
|
||||
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
|
||||
<div class="public-share-row">
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (club.Sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Опубликованных игр пока нет</h2>
|
||||
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in club.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.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private WebPublicClub? club;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
|
||||
private string PublicClubUrl =>
|
||||
club is null
|
||||
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
|
||||
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
club = string.IsNullOrWhiteSpace(Slug)
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@page "/s/{SessionId:guid}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && session 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 (session is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h1>@session.Title</h1>
|
||||
<p>@session.GroupName</p>
|
||||
</section>
|
||||
|
||||
<article class="glass-card public-session-detail">
|
||||
<div class="public-detail-grid">
|
||||
<div>
|
||||
<span>Время</span>
|
||||
<strong>@session.ScheduledAt.FormatMoscow()</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Места</span>
|
||||
<strong>@FormatSeats(session)</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Статус</span>
|
||||
<strong>@TranslateStatus(session.Status)</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||
}
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid SessionId { get; set; }
|
||||
|
||||
private WebPublicSession? session;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
|
||||
|
||||
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
session = await SessionStore.GetPublicSessionAsync(SessionId);
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return null;
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
return null;
|
||||
|
||||
return await sessionStore.GetPublicGroupSettingsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
Guid groupId,
|
||||
string? publicSlug,
|
||||
bool publicScheduleEnabled)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
var normalizedSlug = NormalizePublicSlug(publicSlug);
|
||||
if (publicScheduleEnabled && normalizedSlug is null)
|
||||
{
|
||||
throw new InvalidOperationException("Для публичной страницы нужен короткий адрес.");
|
||||
}
|
||||
|
||||
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||
if (batch is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
JoinLink = joinLink
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizePublicSlug(string? publicSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(publicSlug))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slug = Regex.Replace(publicSlug.Trim().ToLowerInvariant(), @"[\s_]+", "-").Trim('-');
|
||||
if (slug.Length is < 3 or > 80 || !Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$"))
|
||||
{
|
||||
throw new InvalidOperationException("Короткий адрес может содержать только латинские буквы, цифры и дефисы.");
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,41 @@ public sealed record SessionAuditLogEntry(
|
||||
string? NewValue,
|
||||
DateTime ChangedAt);
|
||||
|
||||
public sealed record WebPublicGroupSettings(
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? PublicSlug,
|
||||
bool PublicScheduleEnabled,
|
||||
int PublicSessionCount);
|
||||
|
||||
public sealed record WebPublicSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<WebPublicSession> Sessions);
|
||||
|
||||
public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed record WebGameGroup(
|
||||
public sealed record WebGroupManager(
|
||||
long TelegramId,
|
||||
string? ExternalUserId,
|
||||
string? Platform,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string? ExternalUsername,
|
||||
@@ -35,7 +36,17 @@ public sealed record WebGroupManager(
|
||||
DateTime AddedAt)
|
||||
{
|
||||
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
|
||||
: this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { }
|
||||
: this(telegramId, null, null, displayName, telegramUsername, null, role, addedAt) { }
|
||||
|
||||
public WebGroupManager(
|
||||
long telegramId,
|
||||
string? externalUserId,
|
||||
string displayName,
|
||||
string? telegramUsername,
|
||||
string? externalUsername,
|
||||
string role,
|
||||
DateTime addedAt)
|
||||
: this(telegramId, externalUserId, null, displayName, telegramUsername, externalUsername, role, addedAt) { }
|
||||
}
|
||||
|
||||
public sealed record WebGroupManagement(
|
||||
@@ -56,7 +67,8 @@ public sealed record WebSession(
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||
int? ThreadId = null);
|
||||
int? ThreadId = null,
|
||||
bool IsPublic = false);
|
||||
|
||||
public sealed record WebParticipant(
|
||||
Guid Id,
|
||||
@@ -97,6 +109,7 @@ 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);
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -171,6 +184,184 @@ public sealed class SessionService(
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicGroupSettings>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS PublicSlug,
|
||||
g.public_schedule_enabled AS PublicScheduleEnabled,
|
||||
COALESCE(public_counts.count, 0)::int AS PublicSessionCount
|
||||
FROM game_groups g
|
||||
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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
try
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE game_groups
|
||||
SET public_slug = @PublicSlug,
|
||||
public_schedule_enabled = @PublicScheduleEnabled,
|
||||
public_schedule_updated_at = now()
|
||||
WHERE id = @GroupId
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
|
||||
PublicScheduleEnabled = publicScheduleEnabled
|
||||
});
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
throw new InvalidOperationException("Public slug is already in use.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, "0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, "0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
"""
|
||||
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
|
||||
FROM game_groups g
|
||||
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 g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND lower(g.public_slug) = lower(@Slug)
|
||||
""",
|
||||
new { Slug = slug });
|
||||
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT 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
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
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 s.id = @SessionId
|
||||
AND 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
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
@@ -215,8 +406,13 @@ public sealed class SessionService(
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebGroupManager>(
|
||||
"""
|
||||
SELECT COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
|
||||
SELECT CASE
|
||||
WHEN p.platform = 'Telegram' AND p.external_user_id ~ '^[0-9]+$'
|
||||
THEN p.external_user_id::BIGINT
|
||||
ELSE 0
|
||||
END AS TelegramId,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.platform AS Platform,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
@@ -363,7 +559,8 @@ public sealed class SessionService(
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -401,7 +598,8 @@ public sealed class SessionService(
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -460,7 +658,8 @@ public sealed class SessionService(
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -546,7 +745,8 @@ public sealed class SessionService(
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -672,7 +872,8 @@ public sealed class SessionService(
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1380,6 +1581,62 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT 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
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
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 s.group_id = @GroupId
|
||||
AND 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
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
Guid batchId,
|
||||
|
||||
@@ -785,6 +785,62 @@ select option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-publish-row,
|
||||
.public-settings-actions,
|
||||
.public-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.batch-publish-row {
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.public-settings-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-toggle-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gm-checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gm-checkbox-label input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.public-settings-actions {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.public-link-row {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.public-link-row a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* === Campaign templates === */
|
||||
.campaign-template-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -1620,6 +1676,151 @@ body.telegram-mini-app .session-card-mobile {
|
||||
}
|
||||
}
|
||||
|
||||
/* === Public pages === */
|
||||
.public-shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: rgba(5, 8, 16, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.public-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.public-brand img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.public-content {
|
||||
width: min(960px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.public-hero {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.public-hero-compact {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.public-hero h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.public-hero p {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.public-session-list {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.public-session-card,
|
||||
.public-session-detail {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-session-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.public-session-main h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 0.625rem 0 0.375rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.public-session-meta,
|
||||
.public-detail-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.public-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.public-detail-grid div {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.public-detail-grid span {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.public-detail-grid strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.public-empty-state h2 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.public-empty-state p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.public-topbar {
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.public-session-card,
|
||||
.public-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.public-session-card .btn-gm {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Discord Login Button === */
|
||||
.login-btn-discord {
|
||||
display: flex;
|
||||
|
||||
@@ -115,6 +115,18 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Matches(
|
||||
@"QueryAsync<ulong>[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId",
|
||||
source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldBePlatformNeutral()
|
||||
{
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:3.2.0", compose);
|
||||
Assert.Contains("gmrelay-discord-bot:3.3.0", 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);
|
||||
@@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>3.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("<Version>3.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v3.2.0",
|
||||
"v3.3.0",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +786,9 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public bool CreateBatchFromTemplateCalled { get; private set; }
|
||||
public bool AddCoGmCalled { get; private set; }
|
||||
public bool RemoveCoGmCalled { get; private set; }
|
||||
public bool UpdatePublicGroupSettingsCalled { get; private set; }
|
||||
public bool SetSessionPublicCalled { get; private set; }
|
||||
public bool SetBatchPublicCalled { get; private set; }
|
||||
public Guid? LastUpdatedSessionId { get; private set; }
|
||||
public Guid? LastUpdatedGroupId { get; private set; }
|
||||
public string? LastUpdatedTitle { get; private set; }
|
||||
@@ -821,6 +824,15 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public string? LastAddedCoGmUsername { get; private set; }
|
||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||
public long? LastRemovedCoGmTelegramId { get; private set; }
|
||||
public Guid? LastUpdatedPublicGroupId { get; private set; }
|
||||
public string? LastUpdatedPublicSlug { get; private set; }
|
||||
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
public Guid? LastPublicBatchId { get; private set; }
|
||||
public Guid? LastPublicBatchGroupId { get; private set; }
|
||||
public bool? LastBatchPublicValue { get; private set; }
|
||||
public bool RemovePlayerCalled { get; private set; }
|
||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||
@@ -842,6 +854,67 @@ public sealed class AuthorizedSessionServiceTests
|
||||
return Task.FromResult(group);
|
||||
}
|
||||
|
||||
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
|
||||
{
|
||||
if (!groupsById.TryGetValue(groupId, out var group))
|
||||
{
|
||||
return Task.FromResult<WebPublicGroupSettings?>(null);
|
||||
}
|
||||
|
||||
var publicSessionCount = sessionsById.Values.Count(session => session.GroupId == groupId && session.IsPublic);
|
||||
return Task.FromResult<WebPublicGroupSettings?>(new(
|
||||
groupId,
|
||||
group.Name,
|
||||
"alpha",
|
||||
false,
|
||||
publicSessionCount));
|
||||
}
|
||||
|
||||
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
|
||||
{
|
||||
UpdatePublicGroupSettingsCalled = true;
|
||||
LastUpdatedPublicGroupId = groupId;
|
||||
LastUpdatedPublicSlug = publicSlug;
|
||||
LastUpdatedPublicScheduleEnabled = publicScheduleEnabled;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
{
|
||||
SetSessionPublicCalled = true;
|
||||
LastPublicSessionId = sessionId;
|
||||
LastPublicSessionGroupId = groupId;
|
||||
LastSessionPublicValue = isPublic;
|
||||
|
||||
if (sessionsById.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
sessionsById[sessionId] = session with { IsPublic = isPublic };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
{
|
||||
SetBatchPublicCalled = true;
|
||||
LastPublicBatchId = batchId;
|
||||
LastPublicBatchGroupId = groupId;
|
||||
LastBatchPublicValue = isPublic;
|
||||
|
||||
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
|
||||
{
|
||||
sessionsById[session.Id] = session with { IsPublic = isPublic };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
|
||||
Task.FromResult<WebPublicClub?>(null);
|
||||
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
|
||||
Task.FromResult<WebPublicSession?>(null);
|
||||
|
||||
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||
Task.FromResult(IsManager(groupId, telegramId));
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class GroupDetailsSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GroupDetails_ShouldManageCoGmUsingGroupPlatform()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
|
||||
Assert.Contains("CoGmPlatform", source, StringComparison.Ordinal);
|
||||
Assert.Contains("@CoGmIdLabel", source, StringComparison.Ordinal);
|
||||
Assert.Contains("coGmModel.ExternalUserId", source, StringComparison.Ordinal);
|
||||
Assert.Matches(@"SessionService\.AddCoGmForOwnerAsync\(\s*GroupId,\s*CoGmPlatform,\s*coGmExternalUserId", source);
|
||||
Assert.Matches(@"SessionService\.RemoveCoGmForOwnerAsync\(GroupId,\s*platform,\s*coGmExternalUserId\)", source);
|
||||
Assert.DoesNotContain("Telegram ID co-GM", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("SessionService.RemoveCoGmForOwnerAsync(GroupId, \"Telegram\"", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PublicClubPagesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationV026_ShouldAddPublicationControls()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V026__add_public_club_pages.sql");
|
||||
|
||||
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("public_schedule_enabled", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("is_public", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_game_groups_public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
|
||||
{
|
||||
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
|
||||
|
||||
Assert.Contains("@page \"/club/{Slug}\"", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@page \"/s/{SessionId:guid}\"", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("JoinLink", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("JoinLink", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("WebParticipant", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("WebParticipant", publicSessionPage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStore_ShouldFilterPublicPagesByGroupAndSessionPublication()
|
||||
{
|
||||
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_ShouldManagePublicationSettings()
|
||||
{
|
||||
var groupDetailsPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||
|
||||
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
|
||||
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string PublicQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicClubBySlugAsync", StringComparison.Ordinal);
|
||||
var end = source.IndexOf("public async Task<bool> IsGroupManagerAsync", StringComparison.Ordinal);
|
||||
return source[start..end];
|
||||
}
|
||||
|
||||
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