Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d81564c308 | |||
| accb3b2405 | |||
| a63e3bef1e | |||
| 9d9aca53df | |||
| 5b6971fda5 | |||
| b496a401fc | |||
| 76c6818952 | |||
| 633a020212 | |||
| ab38238fe8 | |||
| 4145cacc52 | |||
| 6d59737d07 | |||
| 71ffcce06b | |||
| 72f43dbef2 | |||
| a5f4a68c6a | |||
| b2497ed877 | |||
| 9b42ea034a | |||
| f94bea3e74 | |||
| cde1e4311f | |||
| 847a40815f | |||
| 6fd03ef836 | |||
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.2.0
|
||||
VERSION: 3.4.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.2.0</Version>
|
||||
<Version>3.4.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v2.8.0`.
|
||||
**Текущая версия:** `v3.4.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.4.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.4.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.4.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -8,8 +8,9 @@ C4Context
|
||||
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||
@@ -19,6 +20,7 @@ C4Context
|
||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||
Rel(player, telegram, "Uses inline buttons")
|
||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||
Rel(visitor, gmrelay, "Views public club and session pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -34,13 +36,14 @@ C4Container
|
||||
|
||||
Person(gm, "Game Master")
|
||||
Person(player, "Player")
|
||||
Person(visitor, "Public visitor")
|
||||
|
||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
@@ -50,6 +53,7 @@ C4Container
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
|
||||
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';
|
||||
@@ -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))
|
||||
|
||||
@@ -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,20 @@
|
||||
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);
|
||||
@@ -73,7 +73,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.2.0</div>
|
||||
<div class="nav-version">v3.4.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="public-shell">
|
||||
<header class="public-topbar">
|
||||
<a class="public-brand" href="/">
|
||||
<img src="/logo.png" alt="GM-Relay" />
|
||||
<span>GM-Relay</span>
|
||||
</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
</header>
|
||||
|
||||
<main class="public-content">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
Произошла непредвиденная ошибка.
|
||||
<a href="." class="reload">Перезагрузить</a>
|
||||
<span class="dismiss">×</span>
|
||||
</div>
|
||||
@@ -40,8 +40,8 @@
|
||||
</span>
|
||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
|
||||
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
|
||||
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@
|
||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
||||
<label class="gm-form-label">@CoGmIdLabel</label>
|
||||
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя</label>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Username</label>
|
||||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
||||
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||
@@ -72,6 +72,58 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (publicSettings is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Публичная страница клуба</h3>
|
||||
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
|
||||
</div>
|
||||
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
|
||||
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group public-toggle-field">
|
||||
<label class="gm-checkbox-label">
|
||||
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
|
||||
<span>Включить публичное расписание</span>
|
||||
</label>
|
||||
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
|
||||
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
|
||||
</button>
|
||||
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||
{
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||
Открыть публичную страницу
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||
{
|
||||
<div class="public-link-row">
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -201,6 +253,17 @@
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<div class="batch-publish-row">
|
||||
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||
@FormatBatchPublication(batch)
|
||||
</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||
@(IsBatchPublishBusy(batch)
|
||||
? "Обновляем..."
|
||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="batch-clone-row">
|
||||
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||
<option value="week">Следующая неделя</option>
|
||||
@@ -249,6 +312,16 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="session-table-actions">
|
||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||
}
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@@ -337,6 +410,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||
}
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@@ -398,17 +480,23 @@
|
||||
private List<WebSession>? sessions;
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private WebPublicGroupSettings? publicSettings;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private Guid? processingTemplateId;
|
||||
private Guid? publishingBatchId;
|
||||
private Guid? publishingSessionId;
|
||||
private string? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private bool savingPublicSettings;
|
||||
private string? currentPlatform;
|
||||
private string? externalUserId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private CoGmEditModel coGmModel = new();
|
||||
private PublicSettingsEditModel publicSettingsModel = new();
|
||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||
private HashSet<Guid> expandedSessions = new();
|
||||
private Guid? kickingParticipantId;
|
||||
@@ -423,6 +511,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
currentPlatform = platform;
|
||||
await LoadSessions();
|
||||
}
|
||||
|
||||
@@ -442,6 +531,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
|
||||
if (publicSettings is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||
if (campaignTemplates is null)
|
||||
{
|
||||
@@ -451,6 +547,92 @@
|
||||
|
||||
RebuildBatchModels();
|
||||
RebuildCampaignTemplateModels();
|
||||
RebuildPublicSettingsModel();
|
||||
}
|
||||
|
||||
private async Task SavePublicSettings()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
savingPublicSettings = true;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
GroupId,
|
||||
publicSettingsModel.PublicSlug,
|
||||
publicSettingsModel.PublicScheduleEnabled);
|
||||
successMessage = "Настройки публичной страницы обновлены.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingPublicSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
publishingBatchId = batch.BatchId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Batch опубликован в публичном расписании."
|
||||
: "Batch скрыт из публичного расписания.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
publishingBatchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
publishingSessionId = sessionId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Сессия опубликована в публичном расписании."
|
||||
: "Сессия скрыта из публичного расписания.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
publishingSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddCoGm()
|
||||
@@ -458,9 +640,16 @@
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
|
||||
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
|
||||
if (coGmExternalUserId.Length == 0)
|
||||
{
|
||||
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
|
||||
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
|
||||
{
|
||||
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,10 +659,10 @@
|
||||
{
|
||||
await SessionService.AddCoGmForOwnerAsync(
|
||||
GroupId,
|
||||
"Telegram",
|
||||
coGmModel.TelegramId.Value.ToString(),
|
||||
CoGmPlatform,
|
||||
coGmExternalUserId,
|
||||
coGmModel.DisplayName,
|
||||
coGmModel.TelegramUsername);
|
||||
coGmModel.ExternalUsername);
|
||||
|
||||
coGmModel = new();
|
||||
successMessage = "Co-GM добавлен.";
|
||||
@@ -493,15 +682,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveCoGm(string coGmExternalUserId)
|
||||
private async Task RemoveCoGm(WebGroupManager manager)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
removingCoGmId = coGmExternalUserId;
|
||||
removingCoGmId = ManagerKey(manager);
|
||||
var platform = ManagerPlatform(manager);
|
||||
var coGmExternalUserId = ManagerExternalUserId(manager);
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
|
||||
successMessage = "Co-GM удалён.";
|
||||
await LoadSessions();
|
||||
}
|
||||
@@ -795,7 +986,9 @@
|
||||
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||
IntervalDays = InferIntervalDays(orderedSessions),
|
||||
SessionCount = orderedSessions.Count
|
||||
SessionCount = orderedSessions.Count,
|
||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||
};
|
||||
})
|
||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||
@@ -823,6 +1016,20 @@
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
private void RebuildPublicSettingsModel()
|
||||
{
|
||||
if (publicSettings is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
publicSettingsModel = new PublicSettingsEditModel
|
||||
{
|
||||
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
|
||||
PublicSlug = publicSettings.PublicSlug ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
||||
{
|
||||
batch.Title = batch.Title.Trim();
|
||||
@@ -832,24 +1039,76 @@
|
||||
|
||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||||
|
||||
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
|
||||
|
||||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
||||
|
||||
private string? PublicClubUrl =>
|
||||
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
|
||||
? null
|
||||
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
|
||||
|
||||
private string PublicSessionUrl(Guid sessionId) =>
|
||||
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
|
||||
|
||||
private static string FormatPublicationStatus(WebSession session) =>
|
||||
session.IsPublic ? "Опубликована" : "Скрыта";
|
||||
|
||||
private static string GetPublicationStatusClass(WebSession session) =>
|
||||
session.IsPublic ? "status-success" : "status-neutral";
|
||||
|
||||
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
|
||||
batch.PublicSessionCount == 0
|
||||
? "Все игры скрыты"
|
||||
: batch.PublicSessionCount == batch.SessionCount
|
||||
? "Все игры опубликованы"
|
||||
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
|
||||
|
||||
private string CoGmPlatform =>
|
||||
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
|
||||
? "Telegram"
|
||||
: groupManagement.Group.Platform;
|
||||
|
||||
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
|
||||
|
||||
private string CurrentUserRole =>
|
||||
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
|
||||
groupManagement?.Managers.FirstOrDefault(manager =>
|
||||
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
|
||||
ManagerExternalUserId(manager) == externalUserId)?.Role
|
||||
?? GroupManagerRoleExtensions.CoGmValue;
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
|
||||
private static string FormatManager(WebGroupManager manager)
|
||||
private string FormatManager(WebGroupManager manager)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "@" + manager.TelegramUsername;
|
||||
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
|
||||
? manager.TelegramUsername
|
||||
: manager.ExternalUsername;
|
||||
var identity = string.IsNullOrWhiteSpace(username)
|
||||
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
|
||||
: "@" + username.TrimStart('@');
|
||||
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
|
||||
}
|
||||
|
||||
private string ManagerPlatform(WebGroupManager manager) =>
|
||||
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
|
||||
|
||||
private static string ManagerExternalUserId(WebGroupManager manager) =>
|
||||
string.IsNullOrWhiteSpace(manager.ExternalUserId)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: manager.ExternalUserId;
|
||||
|
||||
private string ManagerKey(WebGroupManager manager) =>
|
||||
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
|
||||
|
||||
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
|
||||
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
|
||||
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
|
||||
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
|
||||
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
|
||||
|
||||
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||
{
|
||||
if (orderedSessions.Count < 2)
|
||||
@@ -926,6 +1185,8 @@
|
||||
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||
public int IntervalDays { get; set; } = 7;
|
||||
public int SessionCount { get; init; }
|
||||
public int PublicSessionCount { get; init; }
|
||||
public bool AllSessionsPublic { get; init; }
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
@@ -944,8 +1205,14 @@
|
||||
|
||||
private sealed class CoGmEditModel
|
||||
{
|
||||
public long? TelegramId { get; set; }
|
||||
public string ExternalUserId { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string? TelegramUsername { get; set; }
|
||||
public string? ExternalUsername { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PublicSettingsEditModel
|
||||
{
|
||||
public bool PublicScheduleEnabled { get; set; }
|
||||
public string? PublicSlug { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
@page "/club/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && club is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Публичная страница не найдена</h1>
|
||||
<p>Расписание клуба выключено или адрес больше не используется.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (club is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero">
|
||||
<span class="status-badge status-success">Публичное расписание</span>
|
||||
<h1>@club.Name</h1>
|
||||
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
|
||||
<div class="public-share-row">
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (club.Sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Опубликованных игр пока нет</h2>
|
||||
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in club.Sessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private WebPublicClub? club;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
|
||||
private string PublicClubUrl =>
|
||||
club is null
|
||||
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
|
||||
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
club = string.IsNullOrWhiteSpace(Slug)
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
SessionStatus.Planned => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
@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>
|
||||
<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>
|
||||
}
|
||||
<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();
|
||||
|
||||
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,438 @@
|
||||
@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>
|
||||
<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 {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
}
|
||||
|
||||
.showcase-card-club {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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 TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
"Offline" => "Офлайн",
|
||||
"Hybrid" => "Гибрид",
|
||||
_ => format
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return null;
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
return null;
|
||||
|
||||
return await sessionStore.GetPublicGroupSettingsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
Guid groupId,
|
||||
string? publicSlug,
|
||||
bool publicScheduleEnabled)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
var normalizedSlug = NormalizePublicSlug(publicSlug);
|
||||
if (publicScheduleEnabled && normalizedSlug is null)
|
||||
{
|
||||
throw new InvalidOperationException("Для публичной страницы нужен короткий адрес.");
|
||||
}
|
||||
|
||||
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||
if (batch is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
JoinLink = joinLink
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizePublicSlug(string? publicSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(publicSlug))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slug = Regex.Replace(publicSlug.Trim().ToLowerInvariant(), @"[\s_]+", "-").Trim('-');
|
||||
if (slug.Length is < 3 or > 80 || !Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$"))
|
||||
{
|
||||
throw new InvalidOperationException("Короткий адрес может содержать только латинские буквы, цифры и дефисы.");
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Showcase;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
@@ -24,10 +25,41 @@ public sealed record SessionAuditLogEntry(
|
||||
string? NewValue,
|
||||
DateTime ChangedAt);
|
||||
|
||||
public sealed record WebPublicGroupSettings(
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? PublicSlug,
|
||||
bool PublicScheduleEnabled,
|
||||
int PublicSessionCount);
|
||||
|
||||
public sealed record WebPublicSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<WebPublicSession> Sessions);
|
||||
|
||||
public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
@@ -60,6 +92,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(
|
||||
|
||||
@@ -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,25 @@ internal sealed record WebBatchSessionRow(
|
||||
bool TopicCreatedByBot = false);
|
||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
||||
internal sealed record 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);
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -171,6 +203,406 @@ public sealed class SessionService(
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicGroupSettings>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS PublicSlug,
|
||||
g.public_schedule_enabled AS PublicScheduleEnabled,
|
||||
COALESCE(public_counts.count, 0)::int AS PublicSessionCount
|
||||
FROM game_groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
try
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE game_groups
|
||||
SET public_slug = @PublicSlug,
|
||||
public_schedule_enabled = @PublicScheduleEnabled,
|
||||
public_schedule_updated_at = now()
|
||||
WHERE id = @GroupId
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
|
||||
PublicScheduleEnabled = publicScheduleEnabled
|
||||
});
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
throw new InvalidOperationException("Public slug is already in use.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, "0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, "0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||
g.public_slug AS Slug
|
||||
FROM game_groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND lower(g.public_slug) = lower(@Slug)
|
||||
""",
|
||||
new { Slug = slug });
|
||||
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<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
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE 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
|
||||
});
|
||||
|
||||
return rows.Select(r => new ShowcaseSessionDto(
|
||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||
r.Description)).ToList();
|
||||
}
|
||||
|
||||
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
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 +647,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 +800,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 +839,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 +899,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 +986,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 +1113,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 +1822,62 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
Guid batchId,
|
||||
|
||||
@@ -785,6 +785,62 @@ select option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-publish-row,
|
||||
.public-settings-actions,
|
||||
.public-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.batch-publish-row {
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.public-settings-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-toggle-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gm-checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gm-checkbox-label input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.public-settings-actions {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.public-link-row {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.public-link-row a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* === Campaign templates === */
|
||||
.campaign-template-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -1620,6 +1676,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()
|
||||
{
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:3.2.0", compose);
|
||||
Assert.Contains("gmrelay-discord-bot:3.4.0", compose);
|
||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
@@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>3.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("<Version>3.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v3.2.0",
|
||||
"v3.4.0",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public string? LastAddedCoGmUsername { get; private set; }
|
||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||
public long? LastRemovedCoGmTelegramId { get; private set; }
|
||||
public Guid? LastUpdatedPublicGroupId { get; private set; }
|
||||
public string? LastUpdatedPublicSlug { get; private set; }
|
||||
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
public Guid? LastPublicBatchId { get; private set; }
|
||||
public Guid? LastPublicBatchGroupId { get; private set; }
|
||||
public bool? LastBatchPublicValue { get; private set; }
|
||||
public bool RemovePlayerCalled { get; private set; }
|
||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||
@@ -842,6 +855,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));
|
||||
|
||||
@@ -1136,6 +1210,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,88 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PublicClubPagesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationV026_ShouldAddPublicationControls()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V026__add_public_club_pages.sql");
|
||||
|
||||
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("public_schedule_enabled", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("is_public", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_game_groups_public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
|
||||
{
|
||||
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
|
||||
|
||||
Assert.Contains("@page \"/club/{Slug}\"", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@page \"/s/{SessionId:guid}\"", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("JoinLink", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("JoinLink", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("WebParticipant", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("WebParticipant", publicSessionPage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStore_ShouldFilterPublicPagesByGroupAndSessionPublication()
|
||||
{
|
||||
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_ShouldManagePublicationSettings()
|
||||
{
|
||||
var groupDetailsPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||
|
||||
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
|
||||
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string PublicQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicClubBySlugAsync", StringComparison.Ordinal);
|
||||
var end = source.IndexOf("public async Task<bool> IsGroupManagerAsync", StringComparison.Ordinal);
|
||||
return source[start..end];
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user