Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.2.0
|
VERSION: 3.3.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.2.0</Version>
|
<Version>3.3.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру 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.
|
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||||
|
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||||
- **📦 Bulk-операции для Batch Sessions**:
|
- **📦 Bulk-операции для Batch Sessions**:
|
||||||
- обновить общий `title`/`link` у всей пачки;
|
- обновить общий `title`/`link` у всей пачки;
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
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
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.2.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ C4Context
|
|||||||
|
|
||||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
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(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")
|
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(gm, discord, "Uses /newsession and /listsessions")
|
||||||
Rel(player, telegram, "Uses inline buttons")
|
Rel(player, telegram, "Uses inline buttons")
|
||||||
Rel(player, discord, "Uses Join/Leave and RSVP 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(telegram, gmrelay, "Updates via long polling")
|
||||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||||
@@ -34,13 +36,14 @@ C4Container
|
|||||||
|
|
||||||
Person(gm, "Game Master")
|
Person(gm, "Game Master")
|
||||||
Person(player, "Player")
|
Person(player, "Player")
|
||||||
|
Person(visitor, "Public visitor")
|
||||||
|
|
||||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
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(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")
|
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")
|
System_Ext(telegram, "Telegram Bot API")
|
||||||
@@ -50,6 +53,7 @@ C4Container
|
|||||||
Rel(gm, discord, "Slash commands")
|
Rel(gm, discord, "Slash commands")
|
||||||
Rel(player, telegram, "Callback queries")
|
Rel(player, telegram, "Callback queries")
|
||||||
Rel(player, discord, "Button interactions")
|
Rel(player, discord, "Button interactions")
|
||||||
|
Rel(visitor, web, "Read-only public schedule pages")
|
||||||
Rel(telegram, bot, "GetUpdates")
|
Rel(telegram, bot, "GetUpdates")
|
||||||
Rel(discord, discordBot, "Gateway events")
|
Rel(discord, discordBot, "Gateway events")
|
||||||
Rel(bot, telegram, "Bot API calls")
|
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
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
JOIN game_groups g ON g.id = gm.group_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 });
|
new { GuildId = guildId });
|
||||||
|
|
||||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.2.0</div>
|
<div class="nav-version">v3.3.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<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>
|
</span>
|
||||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
@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())">
|
<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 == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||||
<div class="batch-bulk-fields">
|
<div class="batch-bulk-fields">
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
<label class="gm-form-label">@CoGmIdLabel</label>
|
||||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Имя</label>
|
<label class="gm-form-label">Имя</label>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Username</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||||
@@ -72,6 +72,58 @@
|
|||||||
</div>
|
</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))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||||
@@ -201,6 +253,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</EditForm>
|
</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">
|
<div class="batch-clone-row">
|
||||||
<select @bind="batch.CloneInterval" class="gm-form-control">
|
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||||
<option value="week">Следующая неделя</option>
|
<option value="week">Следующая неделя</option>
|
||||||
@@ -249,6 +312,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="session-table-actions">
|
<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 href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
||||||
✏️ Изменить
|
✏️ Изменить
|
||||||
</a>
|
</a>
|
||||||
@@ -337,6 +410,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-card-actions">
|
<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 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>
|
</a>
|
||||||
@@ -398,17 +480,23 @@
|
|||||||
private List<WebSession>? sessions;
|
private List<WebSession>? sessions;
|
||||||
private List<WebCampaignTemplate>? campaignTemplates;
|
private List<WebCampaignTemplate>? campaignTemplates;
|
||||||
private WebGroupManagement? groupManagement;
|
private WebGroupManagement? groupManagement;
|
||||||
|
private WebPublicGroupSettings? publicSettings;
|
||||||
private List<BatchBulkEditModel> batchModels = [];
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
private Guid? processingTemplateId;
|
private Guid? processingTemplateId;
|
||||||
|
private Guid? publishingBatchId;
|
||||||
|
private Guid? publishingSessionId;
|
||||||
private string? removingCoGmId;
|
private string? removingCoGmId;
|
||||||
private bool isAddingCoGm;
|
private bool isAddingCoGm;
|
||||||
|
private bool savingPublicSettings;
|
||||||
|
private string? currentPlatform;
|
||||||
private string? externalUserId;
|
private string? externalUserId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CoGmEditModel coGmModel = new();
|
private CoGmEditModel coGmModel = new();
|
||||||
|
private PublicSettingsEditModel publicSettingsModel = new();
|
||||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||||
private HashSet<Guid> expandedSessions = new();
|
private HashSet<Guid> expandedSessions = new();
|
||||||
private Guid? kickingParticipantId;
|
private Guid? kickingParticipantId;
|
||||||
@@ -423,6 +511,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPlatform = platform;
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +531,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
|
||||||
|
if (publicSettings is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||||
if (campaignTemplates is null)
|
if (campaignTemplates is null)
|
||||||
{
|
{
|
||||||
@@ -451,6 +547,92 @@
|
|||||||
|
|
||||||
RebuildBatchModels();
|
RebuildBatchModels();
|
||||||
RebuildCampaignTemplateModels();
|
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()
|
private async Task AddCoGm()
|
||||||
@@ -458,9 +640,16 @@
|
|||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,10 +659,10 @@
|
|||||||
{
|
{
|
||||||
await SessionService.AddCoGmForOwnerAsync(
|
await SessionService.AddCoGmForOwnerAsync(
|
||||||
GroupId,
|
GroupId,
|
||||||
"Telegram",
|
CoGmPlatform,
|
||||||
coGmModel.TelegramId.Value.ToString(),
|
coGmExternalUserId,
|
||||||
coGmModel.DisplayName,
|
coGmModel.DisplayName,
|
||||||
coGmModel.TelegramUsername);
|
coGmModel.ExternalUsername);
|
||||||
|
|
||||||
coGmModel = new();
|
coGmModel = new();
|
||||||
successMessage = "Co-GM добавлен.";
|
successMessage = "Co-GM добавлен.";
|
||||||
@@ -493,15 +682,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RemoveCoGm(string coGmExternalUserId)
|
private async Task RemoveCoGm(WebGroupManager manager)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
removingCoGmId = coGmExternalUserId;
|
removingCoGmId = ManagerKey(manager);
|
||||||
|
var platform = ManagerPlatform(manager);
|
||||||
|
var coGmExternalUserId = ManagerExternalUserId(manager);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
|
||||||
successMessage = "Co-GM удалён.";
|
successMessage = "Co-GM удалён.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -795,7 +986,9 @@
|
|||||||
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||||
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||||
IntervalDays = InferIntervalDays(orderedSessions),
|
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)
|
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||||
@@ -823,6 +1016,20 @@
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RebuildPublicSettingsModel()
|
||||||
|
{
|
||||||
|
if (publicSettings is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
publicSettingsModel = new PublicSettingsEditModel
|
||||||
|
{
|
||||||
|
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
|
||||||
|
PublicSlug = publicSettings.PublicSlug ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
||||||
{
|
{
|
||||||
batch.Title = batch.Title.Trim();
|
batch.Title = batch.Title.Trim();
|
||||||
@@ -832,24 +1039,76 @@
|
|||||||
|
|
||||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
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 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 =>
|
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;
|
?? GroupManagerRoleExtensions.CoGmValue;
|
||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
|
|
||||||
private static string FormatManager(WebGroupManager manager)
|
private string FormatManager(WebGroupManager manager)
|
||||||
{
|
{
|
||||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
|
||||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
? manager.TelegramUsername
|
||||||
: "@" + 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)
|
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||||
{
|
{
|
||||||
if (orderedSessions.Count < 2)
|
if (orderedSessions.Count < 2)
|
||||||
@@ -926,6 +1185,8 @@
|
|||||||
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||||
public int IntervalDays { get; set; } = 7;
|
public int IntervalDays { get; set; } = 7;
|
||||||
public int SessionCount { get; init; }
|
public int SessionCount { get; init; }
|
||||||
|
public int PublicSessionCount { get; init; }
|
||||||
|
public bool AllSessionsPublic { get; init; }
|
||||||
public string CloneInterval { get; set; } = "week";
|
public string CloneInterval { get; set; } = "week";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,8 +1205,14 @@
|
|||||||
|
|
||||||
private sealed class CoGmEditModel
|
private sealed class CoGmEditModel
|
||||||
{
|
{
|
||||||
public long? TelegramId { get; set; }
|
public string ExternalUserId { get; set; } = "";
|
||||||
public string DisplayName { 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.Security.Claims;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
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)
|
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
var identity = GetCurrentIdentity();
|
var identity = GetCurrentIdentity();
|
||||||
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
JoinLink = joinLink
|
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,
|
string? NewValue,
|
||||||
DateTime ChangedAt);
|
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
|
public interface ISessionStore
|
||||||
{
|
{
|
||||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
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> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed record WebGameGroup(
|
|||||||
public sealed record WebGroupManager(
|
public sealed record WebGroupManager(
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
string? ExternalUserId,
|
string? ExternalUserId,
|
||||||
|
string? Platform,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? TelegramUsername,
|
string? TelegramUsername,
|
||||||
string? ExternalUsername,
|
string? ExternalUsername,
|
||||||
@@ -35,7 +36,17 @@ public sealed record WebGroupManager(
|
|||||||
DateTime AddedAt)
|
DateTime AddedAt)
|
||||||
{
|
{
|
||||||
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, 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(
|
public sealed record WebGroupManagement(
|
||||||
@@ -56,7 +67,8 @@ public sealed record WebSession(
|
|||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
int WaitlistedPlayerCount,
|
int WaitlistedPlayerCount,
|
||||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||||
int? ThreadId = null);
|
int? ThreadId = null,
|
||||||
|
bool IsPublic = false);
|
||||||
|
|
||||||
public sealed record WebParticipant(
|
public sealed record WebParticipant(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -97,6 +109,7 @@ internal sealed record WebBatchSessionRow(
|
|||||||
bool TopicCreatedByBot = false);
|
bool TopicCreatedByBot = false);
|
||||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||||
|
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -171,6 +184,184 @@ public sealed class SessionService(
|
|||||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
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)
|
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
@@ -215,8 +406,13 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return (await conn.QueryAsync<WebGroupManager>(
|
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.external_user_id AS ExternalUserId,
|
||||||
|
p.platform AS Platform,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.external_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
p.external_username AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
@@ -363,7 +559,8 @@ public sealed class SessionService(
|
|||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -401,7 +598,8 @@ public sealed class SessionService(
|
|||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -460,7 +658,8 @@ public sealed class SessionService(
|
|||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||||
@@ -546,7 +745,8 @@ public sealed class SessionService(
|
|||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -672,7 +872,8 @@ public sealed class SessionService(
|
|||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
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(
|
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||||||
Npgsql.NpgsqlConnection conn,
|
Npgsql.NpgsqlConnection conn,
|
||||||
Guid batchId,
|
Guid batchId,
|
||||||
|
|||||||
@@ -785,6 +785,62 @@ select option {
|
|||||||
white-space: nowrap;
|
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 templates === */
|
||||||
.campaign-template-panel {
|
.campaign-template-panel {
|
||||||
margin-bottom: 1.5rem;
|
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 === */
|
/* === Discord Login Button === */
|
||||||
.login-btn-discord {
|
.login-btn-discord {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -115,6 +115,18 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
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]
|
[Fact]
|
||||||
public void Handler_ShouldBePlatformNeutral()
|
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 prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.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("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||||
@@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests
|
|||||||
{
|
{
|
||||||
var repoRoot = GetRepoRoot();
|
var repoRoot = GetRepoRoot();
|
||||||
|
|
||||||
Assert.Contains("<Version>3.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
Assert.Contains("<Version>3.3.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("VERSION: 3.3.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-bot:3.3.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-web:3.3.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("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"v3.2.0",
|
"v3.3.0",
|
||||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
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 CreateBatchFromTemplateCalled { get; private set; }
|
||||||
public bool AddCoGmCalled { get; private set; }
|
public bool AddCoGmCalled { get; private set; }
|
||||||
public bool RemoveCoGmCalled { 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? LastUpdatedSessionId { get; private set; }
|
||||||
public Guid? LastUpdatedGroupId { get; private set; }
|
public Guid? LastUpdatedGroupId { get; private set; }
|
||||||
public string? LastUpdatedTitle { get; private set; }
|
public string? LastUpdatedTitle { get; private set; }
|
||||||
@@ -821,6 +824,15 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public string? LastAddedCoGmUsername { get; private set; }
|
public string? LastAddedCoGmUsername { get; private set; }
|
||||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||||
public long? LastRemovedCoGmTelegramId { 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 bool RemovePlayerCalled { get; private set; }
|
||||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||||
@@ -842,6 +854,67 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.FromResult(group);
|
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) =>
|
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||||
Task.FromResult(IsManager(groupId, 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