Compare commits

..

29 Commits

Author SHA1 Message Date
Toutsu b32f962f11 Merge pull request #113: feat(web): add public master profiles
Deploy Telegram Bot / build-and-push (push) Successful in 6m20s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 28s
2026-05-29 00:22:43 +03:00
Toutsu 0c1d3abd7e feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.

Bump version -> 3.5.0
2026-05-29 00:08:14 +03:00
Toutsu d81564c308 Merge pull request #109: feat: добавить каталог игр и витрину ваншотов (issue #39)
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 17:56:53 +03:00
Toutsu accb3b2405 fix(web): правильный парсинг query string для ?register=1
PR Checks / test-and-build (pull_request) Successful in 12m50s
Заменен Navigation.Uri.Contains() на QueryHelpers.ParseQuery
для корректного определения параметра register без ложных
срабатываний на подстроки (например, register=10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:28:51 +03:00
Toutsu a63e3bef1e fix: финальные правки ревью для issue #39
PR Checks / test-and-build (pull_request) Successful in 13m16s
- PublicSession.razor: добавлена обработка ?register=1, AuthStateProvider,
  TryGetPlatformIdentity, кнопки записи для авторизованных/неавторизованных
  пользователей, отображение результата регистрации
- CreateSessionHandler: добавлен cover_image_url в INSERT SQL
- DiscordProjectStructureTests: версия 3.4.0 во всех проверках
- README.md: актуальная версия v3.4.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:00:33 +03:00
Toutsu 9d9aca53df chore: bump version to 3.4.0 2026-05-28 16:43:11 +03:00
Toutsu 5b6971fda5 test: consolidate capacity tests, add GameSystem edge cases, remove ShowcaseQueryTests 2026-05-28 16:40:11 +03:00
Toutsu b496a401fc test: add GameSystem fuzzy matching and showcase query tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:33:29 +03:00
Toutsu 76c6818952 fix(shared): add missing Platform parameter in players upsert 2026-05-28 16:28:25 +03:00
Toutsu 633a020212 fix(shared): convert GameSystem to string in SQL, guard rollback after commit 2026-05-28 16:26:54 +03:00
Toutsu ab38238fe8 feat(shared): extend CreateSessionCommand with showcase metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:20:44 +03:00
Toutsu 4145cacc52 fix(web): add session-description styles for public session detail 2026-05-28 16:18:58 +03:00
Toutsu 6d59737d07 feat(web): update public session detail with showcase fields
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:13:35 +03:00
Toutsu 71ffcce06b fix(web): add try/finally, concurrency guard, accessible label, registration link to showcase 2026-05-28 16:07:09 +03:00
Toutsu 72f43dbef2 feat(web): add /showcase catalog page with filters
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:00:21 +03:00
Toutsu a5f4a68c6a fix(web): add public-session guards and ON CONFLICT to RegisterFromShowcaseAsync 2026-05-28 15:40:21 +03:00
Toutsu b2497ed877 feat(web): add showcase query and registration methods 2026-05-28 15:27:18 +03:00
Toutsu 9b42ea034a fix(shared): align GameSystem enum with spec, make TryParseFuzzy nullable and display-name based 2026-05-28 15:06:39 +03:00
Toutsu f94bea3e74 feat(shared): add GameSystem enum and Showcase DTOs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:58:49 +03:00
Toutsu cde1e4311f feat(db): V027 add showcase fields to sessions 2026-05-28 14:46:04 +03:00
Toutsu 847a40815f docs: add implementation plan for issue #39 2026-05-28 14:41:12 +03:00
Toutsu 6fd03ef836 docs: add design spec for issue #39 game catalog and showcase 2026-05-28 14:31:17 +03:00
Toutsu c2ccc35e50 Merge pull request #107: feat: add public club pages
Deploy Telegram Bot / build-and-push (push) Successful in 6m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 12:38:33 +03:00
Toutsu 3418d1a46c feat: add public club pages
PR Checks / test-and-build (pull_request) Successful in 12m47s
Add publication settings for clubs and sessions, read-only public club/session pages, dashboard controls, privacy-focused public queries, docs, and tests.

Bump version to 3.3.0
2026-05-28 12:23:47 +03:00
Toutsu fac5d75c7e Fix Discord co-GM management
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 34s
2026-05-27 16:32:47 +03:00
Toutsu 7a2965b43f fix(bot): add missing DI registrations for shared DeleteSessionHandler and ListSessionsHandler
Deploy Telegram Bot / build-and-push (push) Successful in 6m39s
Deploy Telegram Bot / scan-images (push) Successful in 3m26s
Deploy Telegram Bot / deploy (push) Successful in 29s
PR #106 extracted DeleteSessionHandler and ListSessionsHandler to GmRelay.Shared,
but forgot to register the shared implementations in Program.cs. This caused
an InvalidOperationException at startup on Native AOT builds because the Bot
wrappers could not resolve their shared dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:58:51 +03:00
Toutsu a0df94fc91 Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 3m27s
Deploy Telegram Bot / deploy (push) Failing after 1m3s
2026-05-27 15:19:32 +03:00
Toutsu 79694f7de8 Merge pull request #106: refactor: extract remaining Telegram handlers to platform-neutral contracts 2026-05-27 15:19:23 +03:00
Toutsu 64216f5a26 Merge pull request #105: fix template batch topics
Deploy Telegram Bot / build-and-push (push) Successful in 6m3s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-27 14:05:38 +03:00
38 changed files with 4862 additions and 55 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.2.0
VERSION: 3.5.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.2.0</Version>
<Version>3.5.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.8.0`.
**Текущая версия:** `v3.5.0`.
---
@@ -37,6 +37,8 @@
- **📱 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-ссылки не показываются.
- **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
+3 -3
View File
@@ -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.5.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.5.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.5.0
restart: always
depends_on:
db:
+8 -4
View File
@@ -8,17 +8,19 @@ C4Context
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles 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/session/GM profile pages, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(visitor, gmrelay, "Views public club, session, and GM profile pages")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
@@ -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/session/GM profile pages, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, 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 and sanitized GM profile pages")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# Game Catalog and One-Shot Showcase — Design Spec
> Issue #39: feat: добавить каталог игр и витрину ваншотов
---
## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
---
## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
---
## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
---
## Data Model
### New Fields on `sessions` (Migration V027)
| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
### `GameSystem` Enum
```csharp
public enum GameSystem
{
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
OldSchoolEssentials, Dragonbane, BladesInTheDark,
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
Ironsworn, Other
}
```
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
### DTOs
```csharp
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration);
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
GameSystem? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```
---
## UI Design
### `/showcase` — Catalog Page
**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)
**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
### `/s/{id}` — Public Session Detail (Updated)
New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
- Direct registration into `session_participants` via `SessionCapacityRules`
---
## Backend
### ISessionStore Methods
```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```
`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination
`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed
---
## Bot Integration
### Telegram Bot
During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional
During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
### Discord Bot
Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input
---
## Migration V027
```sql
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
```
---
## Testing Strategy
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
---
## Version Bump
Issue label: `type:feature`**minor bump**
Current: `3.3.0` → Next: `3.4.0`
Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria (from Issue #39)
- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
@@ -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';
@@ -0,0 +1,14 @@
-- Showcase fields for game catalog / public session browsing.
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
@@ -0,0 +1,20 @@
-- Public GM profiles for catalog and club trust pages.
CREATE TABLE master_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
public_slug VARCHAR(120),
is_public BOOLEAN NOT NULL DEFAULT false,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ux_master_profiles_public_slug
ON master_profiles (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_master_profiles_public
ON master_profiles (lower(public_slug))
WHERE is_public = true AND public_slug IS NOT NULL;
+2
View File
@@ -73,7 +73,9 @@ builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
@@ -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))
+83
View File
@@ -0,0 +1,83 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain;
public enum GameSystem
{
Dnd5e,
Pathfinder2e,
CallOfCthulhu7e,
Shadowdark,
OldSchoolEssentials,
Dragonbane,
BladesInTheDark,
Daggerheart,
CyberpunkRed,
Mothership,
AlienRpg,
WarhammerFantasy,
VampireMasquerade5e,
StarWarsFfg,
Genesys,
SavageWorlds,
GURPS,
Fate,
DungeonWorld,
Ironsworn,
Other
}
public static class GameSystemExtensions
{
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
new Dictionary<GameSystem, string>
{
[GameSystem.Dnd5e] = "D&D 5e",
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
[GameSystem.Shadowdark] = "Shadowdark",
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
[GameSystem.Dragonbane] = "Dragonbane",
[GameSystem.BladesInTheDark] = "Blades in the Dark",
[GameSystem.Daggerheart] = "Daggerheart",
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
[GameSystem.Mothership] = "Mothership",
[GameSystem.AlienRpg] = "Alien RPG",
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
[GameSystem.Genesys] = "Genesys",
[GameSystem.SavageWorlds] = "Savage Worlds",
[GameSystem.GURPS] = "GURPS",
[GameSystem.Fate] = "Fate",
[GameSystem.DungeonWorld] = "Dungeon World",
[GameSystem.Ironsworn] = "Ironsworn",
[GameSystem.Other] = "Другое"
}.ToFrozenDictionary();
public static string ToDisplayName(this GameSystem system) =>
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
public static GameSystem? TryParseFuzzy(string input)
{
if (string.IsNullOrWhiteSpace(input))
return null;
var normalized = input.Trim().ToLowerInvariant();
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
return exact;
foreach (var value in Enum.GetValues<GameSystem>())
{
if (value == GameSystem.Other)
continue;
var display = value.ToDisplayName().ToLowerInvariant();
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
return value;
}
return GameSystem.Other;
}
}
@@ -1,3 +1,4 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
@@ -9,4 +10,9 @@ public sealed record CreateSessionCommand(
string Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
int? MaxPlayers,
string? ImageReference);
string? ImageReference,
GameSystem? System = null,
string? Description = null,
string? Format = null,
int? DurationMinutes = null,
bool IsOneShot = false);
@@ -16,6 +16,7 @@ public sealed class CreateSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
var platform = command.User.Platform.ToString();
@@ -33,7 +34,7 @@ public sealed class CreateSessionHandler(
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username;
""",
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
@@ -117,8 +118,8 @@ public sealed class CreateSessionHandler(
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
RETURNING id;
""",
new
@@ -129,7 +130,13 @@ public sealed class CreateSessionHandler(
Link = command.Link,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
MaxPlayers = command.MaxPlayers
MaxPlayers = command.MaxPlayers,
System = command.System?.ToString(),
command.Description,
command.Format,
DurationMinutes = command.DurationMinutes,
IsOneShot = command.IsOneShot,
CoverImageUrl = command.ImageReference
},
transaction);
@@ -137,6 +144,7 @@ public sealed class CreateSessionHandler(
}
await transaction.CommitAsync(ct);
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
@@ -150,7 +158,10 @@ public sealed class CreateSessionHandler(
}
catch
{
await transaction.RollbackAsync(ct);
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
throw;
}
}
@@ -0,0 +1,23 @@
namespace GmRelay.Shared.Features.Showcase;
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
string? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter
{
Today,
Tomorrow,
ThisWeek,
All
}
public enum SeatFilter
{
Available,
Waitlist,
Any
}
@@ -0,0 +1,22 @@
namespace GmRelay.Shared.Features.Showcase;
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
@@ -73,7 +73,7 @@
</button>
</form>
<div class="nav-version">v3.2.0</div>
<div class="nav-version">v3.5.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; }
}
}
@@ -4,6 +4,7 @@
@using Microsoft.Extensions.Configuration
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthorizedSessionService AuthorizedSessionService
@inject IConfiguration Configuration
@inject NavigationManager Navigation
@@ -12,6 +13,65 @@
<div class="profile-container">
<h1 class="page-title">Профиль</h1>
@if (masterProfile is not null)
{
<div class="profile-card master-profile-card">
<div class="profile-card-header">
<div>
<h2 class="section-title">Публичный профиль мастера</h2>
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
</div>
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
</div>
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
<span>Опубликовать профиль</span>
</label>
</div>
<div class="profile-form-grid">
<div class="gm-form-group">
<label class="gm-form-label">Имя в публичном профиле</label>
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Описание</label>
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
</button>
@if (PublicMasterProfileUrl is not null)
{
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичный профиль
</a>
}
</div>
</EditForm>
@if (PublicMasterProfileUrl is not null)
{
<div class="public-link-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
}
</div>
}
@if (identities is null)
{
<p class="loading-text">Загрузка...</p>
@@ -92,11 +152,14 @@
@code {
private List<LinkedIdentity>? identities;
private MasterProfileSettings? masterProfile;
private string? currentPlatform;
private string? currentExternalUserId;
private bool isUnlinking;
private bool savingMasterProfile;
private string? errorMessage;
private string? successMessage;
private MasterProfileEditModel masterProfileModel = new();
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
@@ -131,6 +194,7 @@
}
await LoadIdentities();
await LoadMasterProfile();
}
private async Task LoadIdentities()
@@ -152,6 +216,60 @@
}
}
private async Task LoadMasterProfile()
{
try
{
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
if (masterProfile is not null)
{
masterProfileModel = new MasterProfileEditModel
{
DisplayName = masterProfile.DisplayName,
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
IsPublic = masterProfile.IsPublic,
Bio = masterProfile.Bio ?? string.Empty
};
}
}
catch (Exception ex)
{
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
}
}
private string? PublicMasterProfileUrl =>
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
: null;
private async Task SaveMasterProfile()
{
savingMasterProfile = true;
errorMessage = null;
successMessage = null;
try
{
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
masterProfileModel.PublicSlug,
masterProfileModel.IsPublic,
masterProfileModel.DisplayName,
masterProfileModel.Bio);
successMessage = "Публичный профиль мастера обновлён.";
await LoadMasterProfile();
}
catch (Exception ex)
{
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
}
finally
{
savingMasterProfile = false;
}
}
private bool HasLinkedPlatform(string platform)
{
return identities?.Any(i => i.Platform == platform) ?? false;
@@ -188,4 +306,12 @@
isUnlinking = false;
}
}
private sealed class MasterProfileEditModel
{
public string DisplayName { get; set; } = string.Empty;
public string PublicSlug { get; set; } = string.Empty;
public bool IsPublic { get; set; }
public string Bio { get; set; } = string.Empty;
}
}
@@ -0,0 +1,132 @@
@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>
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
{
<div class="public-share-row">
<span>Мастер</span>
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
@(club.MasterDisplayName ?? "Профиль мастера")
</a>
</div>
}
</section>
@if (club.Sessions.Count == 0)
{
<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 MasterProfilePath(string slug) => $"/gm/{slug}";
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,136 @@
@page "/gm/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && profile is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Профиль мастера не найден</h1>
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (profile is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
</HeadContent>
<section class="public-hero public-hero-compact master-profile-hero">
<span class="status-badge status-success">Мастер</span>
<h1>@profile.DisplayName</h1>
@if (!string.IsNullOrWhiteSpace(profile.Bio))
{
<p>@profile.Bio</p>
}
<div class="public-share-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
</section>
@if (profile.Clubs.Count > 0)
{
<section class="glass-card master-profile-section">
<h2>Клубы</h2>
<div class="master-profile-club-list">
@foreach (var club in profile.Clubs)
{
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
}
</div>
</section>
}
@if (profile.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
</div>
}
else
{
<div class="public-session-list">
@foreach (var session in profile.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.GroupName</span>
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
</article>
}
</div>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private GmRelay.Web.Services.PublicMasterProfile? profile;
private bool loaded;
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
private string PublicMasterProfileUrl =>
profile is null
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
profile = string.IsNullOrWhiteSpace(Slug)
? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
loaded = true;
}
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -0,0 +1,248 @@
@page "/s/{SessionId:guid}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
@using GmRelay.Shared.Features.Showcase
@using GmRelay.Web.Services
<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>
@if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
{
<div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
}
<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>
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
{
<div class="public-master-link">
<span>Мастер</span>
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
</div>
}
<div class="session-badges">
@if (!string.IsNullOrWhiteSpace(session.System))
{
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
}
@if (session.IsOneShot)
{
<span class="status-badge status-warning">Ваншот</span>
}
@if (!string.IsNullOrWhiteSpace(session.Format))
{
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
}
</div>
</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>
@if (session.DurationMinutes.HasValue)
{
<div>
<span>Длительность</span>
<strong>@FormatDuration(session.DurationMinutes.Value)</strong>
</div>
}
</div>
@if (!string.IsNullOrWhiteSpace(session.Description))
{
<div class="session-description">
<h3>Описание</h3>
<p>@session.Description</p>
</div>
}
@if (registrationResult is not null)
{
<div class="glass-card @GetRegistrationResultClass()">
<p>@registrationResult</p>
</div>
}
<div class="public-settings-actions">
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
{
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
}
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
{
<a class="btn-gm btn-gm-outline" href="@MasterProfilePath(session.MasterProfileSlug)">Мастер</a>
}
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
@if (session.AllowDirectRegistration)
{
@if (isAuthenticated)
{
<button class="btn-gm btn-gm-primary" @onclick="RegisterAsync">Записаться</button>
}
else
{
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти, чтобы записаться</a>
}
}
</div>
</article>
}
@code {
[Parameter] public Guid SessionId { get; set; }
private ShowcaseSessionDto? session;
private bool loaded;
private bool isAuthenticated;
private string? registrationResult;
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
protected override async Task OnParametersSetAsync()
{
loaded = false;
registrationResult = null;
session = await SessionStore.GetShowcaseSessionAsync(SessionId);
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
var shouldRegister = query.TryGetValue("register", out var val) && val == "1";
if (session is not null && shouldRegister && session.AllowDirectRegistration)
{
if (isAuthenticated && authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
registrationResult = success
? "Вы успешно записались на игру!"
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
}
else if (!isAuthenticated)
{
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}")}");
return;
}
}
loaded = true;
}
private async Task RegisterAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
registrationResult = success
? "Вы успешно записались на игру!"
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
}
}
private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}?register=1")}";
private string GetRegistrationResultClass() => registrationResult?.StartsWith("Вы успешно") == true ? "status-success-bg" : "status-warning-bg";
private static string FormatSeats(ShowcaseSessionDto session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string FormatDuration(int minutes)
{
if (minutes < 60)
return $"{minutes} мин";
var hours = minutes / 60;
var mins = minutes % 60;
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
}
private static string GetSystemDisplayName(string? system)
{
if (string.IsNullOrWhiteSpace(system))
return system ?? string.Empty;
if (Enum.TryParse<GameSystem>(system, out var gs))
return gs.ToDisplayName();
return system;
}
private static string TranslateFormat(string format) => format switch
{
"Online" => "Онлайн",
"Offline" => "Офлайн",
"Hybrid" => "Гибрид",
_ => format
};
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,453 @@
@page "/showcase"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
@using GmRelay.Shared.Features.Showcase
<PageTitle>Каталог игр — GM-Relay</PageTitle>
<HeadContent>
<meta name="description" content="Каталог настольных ролевых игр GM-Relay. Найдите игру по душе — ваншоты, кампании, онлайн и офлайн." />
</HeadContent>
<section class="public-hero">
<h1>Каталог игр</h1>
<p>Найдите настольную ролевую игру по душе — ваншоты, кампании, онлайн и офлайн.</p>
</section>
<section class="glass-card showcase-filters">
<div class="showcase-filter-group">
<span class="showcase-filter-label">Когда</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Date == DateFilter.Today ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Today)">Сегодня</button>
<button class="btn-gm @(filter.Date == DateFilter.Tomorrow ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Tomorrow)">Завтра</button>
<button class="btn-gm @(filter.Date == DateFilter.ThisWeek ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.ThisWeek)">На этой неделе</button>
<button class="btn-gm @(filter.Date == DateFilter.All ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.All)">Все</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Места</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Seats == SeatFilter.Available ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Available)">Есть места</button>
<button class="btn-gm @(filter.Seats == SeatFilter.Waitlist ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Waitlist)">Лист ожидания</button>
<button class="btn-gm @(filter.Seats == SeatFilter.Any ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Any)">Любые</button>
</div>
</div>
<div class="showcase-filter-group">
<label class="showcase-filter-label" for="system-filter">Система</label>
<select id="system-filter" class="gm-form-control showcase-filter-select" aria-label="Система" @onchange="OnSystemChanged">
<option value="" selected="@(filter.System is null)">Любая</option>
@foreach (var system in Enum.GetValues<GameSystem>())
{
var name = system.ToString();
<option value="@name" selected="@(filter.System == name)">@system.ToDisplayName()</option>
}
</select>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Тип</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(true)">Ваншот</button>
<button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(false)">Кампания</button>
<button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(null)">Любое</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Формат</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Online"))">Онлайн</button>
<button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Offline"))">Офлайн</button>
<button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Hybrid"))">Гибрид</button>
<button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat((string?)null))">Любой</button>
</div>
</div>
</section>
@if (loading && sessions.Count == 0)
{
<div class="showcase-grid">
@for (var i = 0; i < 6; i++)
{
<div class="glass-card showcase-card showcase-skeleton">
<div class="skeleton showcase-skeleton-image"></div>
<div class="showcase-card-body">
<div class="skeleton skeleton-text" style="width: 70%;"></div>
<div class="skeleton skeleton-text" style="width: 45%;"></div>
<div class="skeleton skeleton-text" style="width: 55%;"></div>
</div>
</div>
}
</div>
}
else if (!loading && sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Игры не найдены</h2>
<p>Попробуйте изменить фильтры или загляните позже — новые сессии появляются каждый день.</p>
</div>
}
else
{
<div class="showcase-grid">
@foreach (var session in sessions)
{
<article class="glass-card showcase-card animate-fade-in">
<div class="showcase-card-image"
style="@(string.IsNullOrWhiteSpace(session.CoverImageUrl)
? $"background: {GetGradientStyle(session.Id)}; background-size: cover; background-position: center;"
: $"background-image: url({session.CoverImageUrl}); background-size: cover; background-position: center;")">
</div>
<div class="showcase-card-body">
<div class="showcase-card-badges">
@if (!string.IsNullOrWhiteSpace(session.System))
{
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
}
@if (session.IsOneShot)
{
<span class="status-badge status-warning">Ваншот</span>
}
@if (!string.IsNullOrWhiteSpace(session.Format))
{
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
}
</div>
<h2 class="showcase-card-title">@session.Title</h2>
<div class="showcase-card-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
@if (session.DurationMinutes.HasValue)
{
<span>@FormatDuration(session.DurationMinutes.Value)</span>
}
</div>
<div class="showcase-card-seats">
<span>@FormatSeats(session)</span>
</div>
<div class="showcase-card-club">
<span>@session.GroupName</span>
</div>
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
{
<div class="showcase-card-master">
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
</div>
}
<div class="showcase-card-actions">
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
@if (session.AllowDirectRegistration)
{
<a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
}
</div>
</div>
</article>
}
</div>
@if (hasMore)
{
<div class="showcase-load-more">
<button class="btn-gm btn-gm-primary" @onclick="LoadMoreAsync" disabled="@loading">
@if (loading)
{
<span>Загрузка...</span>
}
else
{
<span>Загрузить ещё</span>
}
</button>
</div>
}
}
<style>
.showcase-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
margin-bottom: 1.5rem;
padding: 1.25rem;
}
.showcase-filter-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.showcase-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: 'Jura', sans-serif;
}
.showcase-filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.showcase-filter-buttons .btn-gm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.showcase-filter-select {
min-width: 180px;
width: auto;
}
.showcase-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
@@media (min-width: 640px) {
.showcase-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@media (min-width: 1024px) {
.showcase-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.showcase-card {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.showcase-card-image {
height: 160px;
background-size: cover;
background-position: center;
}
.showcase-card-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.showcase-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.showcase-card-title {
font-size: 1.0625rem;
margin: 0;
font-family: 'Cinzel', serif;
overflow-wrap: anywhere;
}
.showcase-card-meta,
.showcase-card-seats,
.showcase-card-club,
.showcase-card-master {
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'Jura', sans-serif;
}
.showcase-card-club {
color: var(--text-muted);
}
.showcase-card-master a {
color: var(--accent-primary);
text-decoration: none;
font-weight: 600;
}
.showcase-card-actions {
margin-top: auto;
padding-top: 0.75rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.showcase-card-actions .btn-gm {
flex: 1;
justify-content: center;
}
.showcase-load-more {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.showcase-skeleton {
padding: 0;
overflow: hidden;
}
.showcase-skeleton-image {
height: 160px;
border-radius: 0;
}
.showcase-skeleton .showcase-card-body {
padding: 1rem;
}
.showcase-skeleton .skeleton-text {
margin-bottom: 0.5rem;
}
</style>
@code {
private ShowcaseFilter filter = new();
private List<ShowcaseSessionDto> sessions = new();
private bool loading;
private bool hasMore;
private int page = 1;
private const int PageSize = 12;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
page = 1;
sessions.Clear();
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
sessions.AddRange(results);
hasMore = results.Count == PageSize;
}
finally
{
loading = false;
}
}
private async Task LoadMoreAsync()
{
if (loading)
return;
loading = true;
try
{
page++;
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
sessions.AddRange(results);
hasMore = results.Count == PageSize;
}
finally
{
loading = false;
}
}
private async Task OnFilterChanged()
{
await LoadAsync();
}
private async Task SetDate(DateFilter value)
{
filter = filter with { Date = value };
await OnFilterChanged();
}
private async Task SetSeats(SeatFilter value)
{
filter = filter with { Seats = value };
await OnFilterChanged();
}
private async Task OnSystemChanged(ChangeEventArgs e)
{
var value = e.Value?.ToString();
filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value };
await OnFilterChanged();
}
private async Task SetOneShot(bool? value)
{
filter = filter with { IsOneShot = value };
await OnFilterChanged();
}
private async Task SetFormat(string? value)
{
filter = filter with { Format = value };
await OnFilterChanged();
}
private static string GetGradientStyle(Guid id)
{
var bytes = id.ToByteArray();
var hue1 = bytes[0] % 360;
var hue2 = (bytes[1] + 120) % 360;
return $"linear-gradient(135deg, hsl({hue1}, 55%, 28%) 0%, hsl({hue2}, 55%, 20%) 100%)";
}
private static string GetSystemDisplayName(string? system)
{
if (string.IsNullOrWhiteSpace(system))
return system ?? string.Empty;
if (Enum.TryParse<GameSystem>(system, out var gs))
return gs.ToDisplayName();
return system;
}
private static string FormatSeats(ShowcaseSessionDto session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
if (session.WaitlistedPlayerCount > 0)
seats += $", ожидание {session.WaitlistedPlayerCount}";
return seats;
}
private static string FormatDuration(int minutes)
{
if (minutes < 60)
return $"{minutes} мин";
var hours = minutes / 60;
var mins = minutes % 60;
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
}
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
private static string TranslateFormat(string format) => format switch
{
"Online" => "Онлайн",
"Offline" => "Офлайн",
"Hybrid" => "Гибрид",
_ => format
};
}
+6
View File
@@ -161,6 +161,7 @@ app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService aut
app.MapPost("/auth/telegram-webapp", async (
HttpContext context,
TelegramAuthService authService,
ISessionStore sessionStore,
TelegramWebAppAuthRequest request) =>
{
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
@@ -168,6 +169,8 @@ app.MapPost("/auth/telegram-webapp", async (
return Results.Unauthorized();
}
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
@@ -180,6 +183,7 @@ app.MapPost("/auth/telegram-webapp", async (
app.MapPost("/auth/telegram-login", async (
HttpContext context,
TelegramAuthService authService,
ISessionStore sessionStore,
TelegramLoginPayload request) =>
{
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
@@ -187,6 +191,8 @@ app.MapPost("/auth/telegram-login", async (
return Results.Unauthorized();
}
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
@@ -54,6 +55,117 @@ 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 Task<MasterProfileSettings?> GetMasterProfileSettingsForCurrentUserAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return Task.FromResult<MasterProfileSettings?>(null);
return sessionStore.GetMasterProfileSettingsAsync(identity.Value.Platform, identity.Value.ExternalUserId);
}
public async Task UpdateMasterProfileSettingsForCurrentUserAsync(
string? publicSlug,
bool isPublic,
string displayName,
string? bio)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var normalizedDisplayName = displayName.Trim();
if (normalizedDisplayName.Length is < 2 or > 120)
{
throw new InvalidOperationException("Имя профиля должно быть от 2 до 120 символов.");
}
var normalizedBio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim();
if (normalizedBio?.Length > 1200)
{
throw new InvalidOperationException("Описание профиля должно быть не длиннее 1200 символов.");
}
var normalizedSlug = NormalizeMasterProfileSlug(publicSlug);
if (isPublic && normalizedSlug is null)
{
throw new InvalidOperationException("Для публичного профиля нужен короткий адрес.");
}
await sessionStore.UpdateMasterProfileSettingsAsync(
identity.Value.Platform,
identity.Value.ExternalUserId,
normalizedSlug,
isPublic,
normalizedDisplayName,
normalizedBio);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
{
var identity = GetCurrentIdentity();
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 +502,22 @@ 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;
}
private static string? NormalizeMasterProfileSlug(string? publicSlug) => NormalizePublicSlug(publicSlug);
}
+63
View File
@@ -1,4 +1,5 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Showcase;
namespace GmRelay.Web.Services;
@@ -24,10 +25,64 @@ 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,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
string Slug,
IReadOnlyList<WebPublicSession> Sessions,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record MasterProfileSettings(
Guid PlayerId,
string DisplayName,
string? PublicSlug,
bool IsPublic,
string? Bio);
public sealed record PublicMasterClub(
Guid GroupId,
string Name,
string Slug);
public sealed record PublicMasterProfile(
string Slug,
string DisplayName,
string? Bio,
IReadOnlyList<PublicMasterClub> Clubs,
IReadOnlyList<WebPublicSession> Sessions);
public interface ISessionStore
{
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);
@@ -53,6 +108,9 @@ public interface ISessionStore
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
@@ -60,6 +118,11 @@ public interface ISessionStore
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
// --- Showcase / game catalog (issue #39) ---
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
}
public sealed record LinkedIdentity(
+764 -8
View File
@@ -1,5 +1,6 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Showcase;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
@@ -28,6 +29,7 @@ public sealed record WebGameGroup(
public sealed record WebGroupManager(
long TelegramId,
string? ExternalUserId,
string? Platform,
string DisplayName,
string? TelegramUsername,
string? ExternalUsername,
@@ -35,7 +37,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 +68,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 +110,33 @@ 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,
string? MasterProfileSlug,
string? MasterDisplayName);
internal sealed record ShowcaseSessionRow(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description,
string? MasterProfileSlug,
string? MasterDisplayName);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -171,6 +211,465 @@ 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,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
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 gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND lower(g.public_slug) = lower(@Slug)
""",
new { Slug = slug, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
if (group is null)
{
return null;
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
}
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,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
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
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
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,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
}
public async Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<ShowcaseSessionRow>(
"""
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.system AS System,
s.is_one_shot AS IsOneShot,
s.format AS Format,
s.duration_minutes AS DurationMinutes,
s.cover_image_url AS CoverImageUrl,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
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
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
@DateFilter = 'All'
OR (@DateFilter = 'Today' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '1 day')
OR (@DateFilter = 'Tomorrow' AND s.scheduled_at >= CURRENT_DATE + interval '1 day' AND s.scheduled_at < CURRENT_DATE + interval '2 days')
OR (@DateFilter = 'ThisWeek' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '7 days')
)
AND (
@SeatFilter = 'Any'
OR (@SeatFilter = 'Available' AND (s.max_players IS NULL OR active_counts.count < s.max_players))
OR (@SeatFilter = 'Waitlist' AND (s.max_players IS NOT NULL AND active_counts.count >= s.max_players))
)
AND (@System IS NULL OR s.system = @System)
AND (@IsOneShot IS NULL OR s.is_one_shot = @IsOneShot)
AND (@Format IS NULL OR s.format = @Format)
ORDER BY s.scheduled_at ASC
LIMIT @PageSize OFFSET @Offset
""",
new
{
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
DateFilter = filter.Date.ToString(),
SeatFilter = filter.Seats.ToString(),
filter.System,
filter.IsOneShot,
filter.Format,
PageSize = pageSize,
Offset = (page - 1) * pageSize,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
return rows.Select(r => new ShowcaseSessionDto(
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
}
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
"""
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.system AS System,
s.is_one_shot AS IsOneShot,
s.format AS Format,
s.duration_minutes AS DurationMinutes,
s.cover_image_url AS CoverImageUrl,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
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
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
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,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
if (row is null)
return null;
return new ShowcaseSessionDto(
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
}
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<dynamic>(
"""
SELECT s.id, s.max_players AS MaxPlayers, s.allow_direct_registration AS AllowDirectRegistration
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.is_public = true
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
FOR UPDATE OF s
""",
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled },
transaction);
if (session is null || !(bool)session.allowdirectregistration)
{
await transaction.RollbackAsync();
return false;
}
var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction);
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
(int?)session.maxplayers,
await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*) FROM session_participants
WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
transaction));
var inserted = await conn.ExecuteAsync(
"""
INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
ON CONFLICT (session_id, player_id) DO NOTHING
""",
new
{
SessionId = sessionId,
PlayerId = playerId,
Pending = RsvpStatus.Pending,
RegistrationStatus = registrationStatus
},
transaction);
if (inserted == 0)
{
await transaction.RollbackAsync();
return false;
}
await transaction.CommitAsync();
return true;
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -215,8 +714,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 +867,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 +906,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 +966,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 +1053,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 +1180,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 +1889,253 @@ public sealed class SessionService(
}
}
public async Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
return null;
return await conn.QuerySingleOrDefaultAsync<MasterProfileSettings>(
"""
SELECT p.id AS PlayerId,
COALESCE(mp.display_name, p.display_name) AS DisplayName,
mp.public_slug AS PublicSlug,
COALESCE(mp.is_public, false) AS IsPublic,
mp.bio AS Bio
FROM players p
LEFT JOIN master_profiles mp ON mp.player_id = p.id
WHERE p.id = @PlayerId
""",
new { PlayerId = effectiveId.Value });
}
public async Task UpdateMasterProfileSettingsAsync(
string platform,
string externalUserId,
string? publicSlug,
bool isPublic,
string displayName,
string? bio)
{
await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
throw new InvalidOperationException("Current player not found.");
try
{
await conn.ExecuteAsync(
"""
INSERT INTO master_profiles (player_id, public_slug, is_public, display_name, bio)
VALUES (@PlayerId, @PublicSlug, @IsPublic, @DisplayName, @Bio)
ON CONFLICT (player_id) DO UPDATE
SET public_slug = EXCLUDED.public_slug,
is_public = EXCLUDED.is_public,
display_name = EXCLUDED.display_name,
bio = EXCLUDED.bio,
updated_at = now()
""",
new
{
PlayerId = effectiveId.Value,
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
IsPublic = isPublic,
DisplayName = displayName,
Bio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim()
});
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
{
throw new InvalidOperationException("Master profile slug is already in use.", ex);
}
}
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
{
await using var conn = await dataSource.OpenConnectionAsync();
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
"""
SELECT mp.player_id AS PlayerId,
mp.public_slug AS Slug,
mp.display_name AS DisplayName,
mp.bio AS Bio
FROM master_profiles mp
WHERE mp.is_public = true
AND mp.public_slug IS NOT NULL
AND lower(mp.public_slug) = lower(@Slug)
""",
new { Slug = slug });
if (profile is null)
return null;
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
}
private static async Task<List<PublicMasterClub>> GetPublicClubsForMasterAsync(
NpgsqlConnection conn,
Guid playerId)
{
return (await conn.QueryAsync<PublicMasterClub>(
"""
SELECT DISTINCT g.id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.public_slug AS Slug
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
WHERE COALESCE(manager_link.primary_player_id, gm.player_id) = @PlayerId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
ORDER BY Name
""",
new { PlayerId = playerId })).ToList();
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
NpgsqlConnection conn,
Guid playerId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
SELECT DISTINCT s.id AS Id,
s.group_id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
g.public_slug AS GroupSlug,
s.title AS Title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
JOIN group_managers gm ON gm.group_id = g.id
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
JOIN master_profiles mp ON mp.player_id = COALESCE(manager_link.primary_player_id, gm.player_id)
AND mp.player_id = @PlayerId
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
LEFT JOIN LATERAL (
SELECT recent.title
FROM sessions recent
WHERE recent.group_id = g.id
ORDER BY recent.scheduled_at DESC
LIMIT 1
) latest_session ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Active
) active_counts ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
ORDER BY s.scheduled_at
""",
new
{
PlayerId = playerId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
})).ToList();
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
NpgsqlConnection conn,
Guid groupId)
{
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,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
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
LEFT JOIN LATERAL (
SELECT gm.player_id
FROM group_managers gm
WHERE gm.group_id = g.id
AND gm.role = @OwnerRole
ORDER BY gm.added_at
LIMIT 1
) owner_manager ON true
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
AND mp.is_public = true
AND mp.public_slug IS NOT NULL
WHERE s.group_id = @GroupId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
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,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
})).ToList();
}
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
Npgsql.NpgsqlConnection conn,
Guid batchId,
+271
View File
@@ -785,6 +785,114 @@ 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;
}
.profile-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.profile-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.master-profile-bio {
min-height: 7rem;
resize: vertical;
}
.master-profile-section {
margin-bottom: 1rem;
}
.master-profile-section h2 {
margin: 0 0 0.75rem;
font-family: 'Cinzel', serif;
}
.master-profile-club-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.master-profile-club-list a {
text-decoration: none;
}
.public-master-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
font-family: 'Jura', sans-serif;
}
.public-master-link a {
color: var(--accent-primary);
font-weight: 600;
text-decoration: none;
}
/* === Campaign templates === */
.campaign-template-panel {
margin-bottom: 1.5rem;
@@ -1620,6 +1728,169 @@ 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);
}
.session-description {
margin: 1.25rem 0;
padding: 1rem;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.session-description h3 {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.session-description p {
margin: 0;
color: var(--text-secondary);
}
.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()
{
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Xml.Linq;
namespace GmRelay.Bot.Tests.Discord;
@@ -61,8 +62,9 @@ public sealed class DiscordProjectStructureTests
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
var version = GetProjectVersion(repoRoot);
Assert.Contains("gmrelay-discord-bot:3.2.0", compose);
Assert.Contains($"gmrelay-discord-bot:{version}", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,14 +77,15 @@ public sealed class DiscordProjectStructureTests
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
{
var repoRoot = GetRepoRoot();
var version = GetProjectVersion(repoRoot);
Assert.Contains("<Version>3.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>{version}</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains($"VERSION: {version}", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains($"gmrelay-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains($"gmrelay-web:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains($"gmrelay-discord-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v3.2.0",
$"v{version}",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
@@ -121,4 +124,13 @@ public sealed class DiscordProjectStructureTests
Assert.Contains("test:", discordBlock);
Assert.Contains("localhost:8082/health", discordBlock);
}
private static string GetProjectVersion(string repoRoot)
{
var props = XDocument.Load(Path.Combine(repoRoot, "Directory.Build.props"));
return props.Root?
.Element("PropertyGroup")?
.Element("Version")?
.Value ?? throw new InvalidOperationException("Version not found.");
}
}
@@ -0,0 +1,65 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class GameSystemTests
{
[Theory]
[InlineData("Dnd5e", GameSystem.Dnd5e)]
[InlineData("D&D", GameSystem.Dnd5e)]
[InlineData("dnd5e", GameSystem.Dnd5e)]
[InlineData(" dnd5e ", GameSystem.Dnd5e)]
[InlineData("D&D 5e", GameSystem.Dnd5e)]
[InlineData("pathfinder", GameSystem.Pathfinder2e)]
[InlineData("call of cthulhu", GameSystem.CallOfCthulhu7e)]
[InlineData("shadow", GameSystem.Shadowdark)]
[InlineData("dark", GameSystem.Shadowdark)]
[InlineData("unknown xyz", GameSystem.Other)]
public void TryParseFuzzy_ShouldMapInputToExpectedSystem(string input, GameSystem expected)
{
var result = GameSystemExtensions.TryParseFuzzy(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("днд")]
[InlineData("колова")]
public void TryParseFuzzy_ShouldReturnOtherForUnmatchedCyrillicInput(string input)
{
var result = GameSystemExtensions.TryParseFuzzy(input);
Assert.Equal(GameSystem.Other, result);
}
[Fact]
public void TryParseFuzzy_ShouldReturnNullForNullInput()
{
var result = GameSystemExtensions.TryParseFuzzy(null!);
Assert.Null(result);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void TryParseFuzzy_ShouldReturnNullForEmptyOrWhitespaceInput(string input)
{
var result = GameSystemExtensions.TryParseFuzzy(input);
Assert.Null(result);
}
[Theory]
[InlineData(GameSystem.Dnd5e, "D&D 5e")]
[InlineData(GameSystem.Other, "Другое")]
[InlineData(GameSystem.Pathfinder2e, "Pathfinder 2e")]
[InlineData(GameSystem.Shadowdark, "Shadowdark")]
[InlineData((GameSystem)999, "Другое")]
public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected)
{
var result = system.ToDisplayName();
Assert.Equal(expected, result);
}
}
@@ -21,6 +21,30 @@ public sealed class SessionCapacityRulesTests
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
}
[Fact]
public void DecideJoinStatus_ShouldReturnActive_WhenUnlimitedSeats()
{
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5);
Assert.Equal(ParticipantRegistrationStatus.Active, status);
}
[Fact]
public void DecideJoinStatus_ShouldReturnWaitlisted_WhenOverCapacity()
{
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5);
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
}
[Fact]
public void DecideJoinStatus_ShouldReturnActive_WhenZeroActiveAndPositiveMax()
{
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0);
Assert.Equal(ParticipantRegistrationStatus.Active, status);
}
[Fact]
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
{
@@ -2,6 +2,7 @@ using GmRelay.Web.Services;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Showcase;
namespace GmRelay.Bot.Tests.Web;
@@ -786,6 +787,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 +825,23 @@ 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 MasterProfileSettings? MasterProfileSettings { get; set; } = new(Guid.NewGuid(), "Owner GM", null, false, null);
public bool UpdateMasterProfileCalled { get; private set; }
public string? LastMasterProfilePlatform { get; private set; }
public string? LastMasterProfileExternalUserId { get; private set; }
public string? LastMasterProfileSlug { get; private set; }
public bool? LastMasterProfileIsPublic { get; private set; }
public string? LastMasterProfileDisplayName { get; private set; }
public string? LastMasterProfileBio { get; private set; }
public Guid? LastPublicSessionId { get; private set; }
public Guid? LastPublicSessionGroupId { get; private set; }
public bool? LastSessionPublicValue { get; private set; }
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 +863,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));
@@ -1121,6 +1203,25 @@ public sealed class AuthorizedSessionServiceTests
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) =>
Task.FromResult(MasterProfileSettings);
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio)
{
UpdateMasterProfileCalled = true;
LastMasterProfilePlatform = platform;
LastMasterProfileExternalUserId = externalUserId;
LastMasterProfileSlug = publicSlug;
LastMasterProfileIsPublic = isPublic;
LastMasterProfileDisplayName = displayName;
LastMasterProfileBio = bio;
MasterProfileSettings = new(Guid.NewGuid(), displayName, publicSlug, isPublic, bio);
return Task.CompletedTask;
}
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) =>
Task.FromResult<PublicMasterProfile?>(null);
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(Guid.NewGuid());
@@ -1136,6 +1237,15 @@ public sealed class AuthorizedSessionServiceTests
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
public Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) =>
Task.FromResult<IReadOnlyList<ShowcaseSessionDto>>([]);
public Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) =>
Task.FromResult<ShowcaseSessionDto?>(null);
public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) =>
Task.FromResult(false);
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == 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,188 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class MasterProfilesTests
{
[Fact]
public async Task MigrationV028_ShouldAddMasterProfilesWithoutExternalIdentifiers()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql");
Assert.Contains("CREATE TABLE master_profiles", migration, StringComparison.Ordinal);
Assert.Contains("player_id", migration, StringComparison.Ordinal);
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
Assert.Contains("is_public", migration, StringComparison.Ordinal);
Assert.Contains("display_name", migration, StringComparison.Ordinal);
Assert.Contains("bio", migration, StringComparison.Ordinal);
Assert.Contains("ux_master_profiles_public_slug", migration, StringComparison.Ordinal);
Assert.DoesNotContain("external_user_id", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("telegram_id", migration, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("discord", migration, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SessionStore_ShouldExposeSanitizedMasterProfileContracts()
{
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
Assert.Contains("MasterProfileSettings", sessionStore, StringComparison.Ordinal);
Assert.Contains("PublicMasterProfile", sessionStore, StringComparison.Ordinal);
Assert.Contains("PublicMasterClub", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("UpdateMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPublicMasterProfileBySlugAsync", sessionStore, StringComparison.Ordinal);
var publicProfileSection = RecordSection(sessionStore, "PublicMasterProfile");
Assert.DoesNotContain("AvatarUrl", publicProfileSection, StringComparison.Ordinal);
Assert.DoesNotContain("ExternalUserId", publicProfileSection, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramId", publicProfileSection, StringComparison.Ordinal);
Assert.DoesNotContain("DiscordId", publicProfileSection, StringComparison.Ordinal);
}
[Fact]
public async Task PublicMasterProfilePage_ShouldBePublicAndHideTechnicalIdentityData()
{
var publicProfilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor");
Assert.Contains("@page \"/gm/{Slug}\"", publicProfilePage, StringComparison.Ordinal);
Assert.Contains("@layout PublicLayout", publicProfilePage, StringComparison.Ordinal);
Assert.Contains("GetPublicMasterProfileBySlugAsync", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("@attribute [Authorize]", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("ExternalUserId", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramId", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("DiscordId", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("AvatarUrl", publicProfilePage, StringComparison.Ordinal);
Assert.DoesNotContain("LinkedIdentity", publicProfilePage, StringComparison.Ordinal);
}
[Fact]
public async Task ProfilePage_ShouldManageMasterProfilePublication()
{
var profilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Profile.razor");
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
Assert.Contains("GetMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
Assert.Contains("UpdateMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
Assert.Contains("masterProfileModel", profilePage, StringComparison.Ordinal);
Assert.Contains("PublicMasterProfileUrl", profilePage, StringComparison.Ordinal);
Assert.Contains("NormalizeMasterProfileSlug", authorizedService, StringComparison.Ordinal);
}
[Fact]
public async Task TelegramLoginEndpoints_ShouldUpsertPlayersForProfileManagement()
{
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
}
[Fact]
public async Task PublicGamePages_ShouldLinkPublishedMasterProfilesWithoutPrivateIds()
{
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
var showcasePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Showcase.razor");
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("MasterProfileSlug", publicClubPage, StringComparison.Ordinal);
Assert.Contains("MasterProfileSlug", publicSessionPage, StringComparison.Ordinal);
Assert.Contains("MasterProfileSlug", showcasePage, StringComparison.Ordinal);
Assert.Contains("master_profiles", sessionService, StringComparison.Ordinal);
Assert.DoesNotContain("targetExternalUserId", PublicQuerySection(sessionService), StringComparison.Ordinal);
Assert.DoesNotContain("target_platform", PublicQuerySection(sessionService), StringComparison.Ordinal);
}
[Fact]
public async Task PublicMasterProfileQueries_ShouldIncludeCoGmManagedPublishedGames()
{
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
var clubsQuery = MethodSection(sessionService, "GetPublicClubsForMasterAsync");
var sessionsQuery = MethodSection(sessionService, "GetPublicSessionsForMasterAsync");
Assert.Contains("JOIN group_managers gm", clubsQuery, StringComparison.Ordinal);
Assert.Contains("JOIN group_managers gm", sessionsQuery, StringComparison.Ordinal);
Assert.DoesNotContain("gm.role = @OwnerRole", clubsQuery, StringComparison.Ordinal);
Assert.DoesNotContain("gm.role = @OwnerRole", sessionsQuery, StringComparison.Ordinal);
}
private static string RecordSection(string source, string recordName)
{
var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf(");", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..(end + 2)];
}
private static string PublicQuerySection(string source)
{
var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..end];
}
private static string MethodSection(string source, string methodName)
{
var start = -1;
var searchFrom = 0;
while (searchFrom < source.Length)
{
var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal);
if (candidate < 0)
return string.Empty;
var lineStart = source.LastIndexOf('\n', candidate);
var headerStart = lineStart < 0 ? 0 : lineStart + 1;
var header = source[headerStart..candidate];
if (header.Contains("private static async Task", StringComparison.Ordinal))
{
start = candidate;
break;
}
searchFrom = candidate + methodName.Length;
}
var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal);
if (nextMethod < 0)
{
nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal);
}
return nextMethod < 0 ? source[start..] : source[start..nextMethod];
}
private static string EndpointSection(string source, string route)
{
var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal);
return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint];
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -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}'.");
}
}