Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c1d3abd7e | |||
| d81564c308 | |||
| accb3b2405 | |||
| a63e3bef1e | |||
| 9d9aca53df | |||
| 5b6971fda5 | |||
| b496a401fc | |||
| 76c6818952 | |||
| 633a020212 | |||
| ab38238fe8 | |||
| 4145cacc52 | |||
| 6d59737d07 | |||
| 71ffcce06b | |||
| 72f43dbef2 | |||
| a5f4a68c6a | |||
| b2497ed877 | |||
| 9b42ea034a | |||
| f94bea3e74 | |||
| cde1e4311f | |||
| 847a40815f | |||
| 6fd03ef836 | |||
| c2ccc35e50 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.3.0
|
||||
VERSION: 3.5.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.3.0</Version>
|
||||
<Version>3.5.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v3.3.0`.
|
||||
**Текущая версия:** `v3.5.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -8,19 +8,19 @@ C4Context
|
||||
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules without private player data")
|
||||
Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile pages, and shared scheduling logic")
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles")
|
||||
|
||||
Rel(gm, telegram, "Creates and manages sessions")
|
||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||
Rel(player, telegram, "Uses inline buttons")
|
||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||
Rel(visitor, gmrelay, "Views public club and session pages")
|
||||
Rel(visitor, gmrelay, "Views public club, session, and GM profile pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -41,9 +41,9 @@ C4Container
|
||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile pages, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
@@ -53,7 +53,7 @@ C4Container
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule pages")
|
||||
Rel(visitor, web, "Read-only public schedule and sanitized GM profile pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
|
||||
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,14 @@
|
||||
-- Showcase fields for game catalog / public session browsing.
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN system VARCHAR(50),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN cover_image_url TEXT,
|
||||
ADD COLUMN duration_minutes INTEGER,
|
||||
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Public GM profiles for catalog and club trust pages.
|
||||
|
||||
CREATE TABLE master_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||
public_slug VARCHAR(120),
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
bio TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ux_master_profiles_public_slug
|
||||
ON master_profiles (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_master_profiles_public
|
||||
ON master_profiles (lower(public_slug))
|
||||
WHERE is_public = true AND public_slug IS NOT NULL;
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum GameSystem
|
||||
{
|
||||
Dnd5e,
|
||||
Pathfinder2e,
|
||||
CallOfCthulhu7e,
|
||||
Shadowdark,
|
||||
OldSchoolEssentials,
|
||||
Dragonbane,
|
||||
BladesInTheDark,
|
||||
Daggerheart,
|
||||
CyberpunkRed,
|
||||
Mothership,
|
||||
AlienRpg,
|
||||
WarhammerFantasy,
|
||||
VampireMasquerade5e,
|
||||
StarWarsFfg,
|
||||
Genesys,
|
||||
SavageWorlds,
|
||||
GURPS,
|
||||
Fate,
|
||||
DungeonWorld,
|
||||
Ironsworn,
|
||||
Other
|
||||
}
|
||||
|
||||
public static class GameSystemExtensions
|
||||
{
|
||||
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
|
||||
new Dictionary<GameSystem, string>
|
||||
{
|
||||
[GameSystem.Dnd5e] = "D&D 5e",
|
||||
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
|
||||
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
|
||||
[GameSystem.Shadowdark] = "Shadowdark",
|
||||
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
|
||||
[GameSystem.Dragonbane] = "Dragonbane",
|
||||
[GameSystem.BladesInTheDark] = "Blades in the Dark",
|
||||
[GameSystem.Daggerheart] = "Daggerheart",
|
||||
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
|
||||
[GameSystem.Mothership] = "Mothership",
|
||||
[GameSystem.AlienRpg] = "Alien RPG",
|
||||
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
|
||||
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
|
||||
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
|
||||
[GameSystem.Genesys] = "Genesys",
|
||||
[GameSystem.SavageWorlds] = "Savage Worlds",
|
||||
[GameSystem.GURPS] = "GURPS",
|
||||
[GameSystem.Fate] = "Fate",
|
||||
[GameSystem.DungeonWorld] = "Dungeon World",
|
||||
[GameSystem.Ironsworn] = "Ironsworn",
|
||||
[GameSystem.Other] = "Другое"
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
public static string ToDisplayName(this GameSystem system) =>
|
||||
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
|
||||
|
||||
public static GameSystem? TryParseFuzzy(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return null;
|
||||
|
||||
var normalized = input.Trim().ToLowerInvariant();
|
||||
|
||||
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
|
||||
return exact;
|
||||
|
||||
foreach (var value in Enum.GetValues<GameSystem>())
|
||||
{
|
||||
if (value == GameSystem.Other)
|
||||
continue;
|
||||
|
||||
var display = value.ToDisplayName().ToLowerInvariant();
|
||||
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
|
||||
return value;
|
||||
}
|
||||
|
||||
return GameSystem.Other;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
@@ -9,4 +10,9 @@ public sealed record CreateSessionCommand(
|
||||
string Link,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||
int? MaxPlayers,
|
||||
string? ImageReference);
|
||||
string? ImageReference,
|
||||
GameSystem? System = null,
|
||||
string? Description = null,
|
||||
string? Format = null,
|
||||
int? DurationMinutes = null,
|
||||
bool IsOneShot = false);
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class CreateSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var transactionCommitted = false;
|
||||
try
|
||||
{
|
||||
var platform = command.User.Platform.ToString();
|
||||
@@ -33,7 +34,7 @@ public sealed class CreateSessionHandler(
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
external_username = EXCLUDED.external_username;
|
||||
""",
|
||||
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
|
||||
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
|
||||
transaction);
|
||||
|
||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||
@@ -117,8 +118,8 @@ public sealed class CreateSessionHandler(
|
||||
{
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
@@ -129,7 +130,13 @@ public sealed class CreateSessionHandler(
|
||||
Link = command.Link,
|
||||
ScheduledAt = scheduledAt,
|
||||
Status = SessionStatus.Planned,
|
||||
MaxPlayers = command.MaxPlayers
|
||||
MaxPlayers = command.MaxPlayers,
|
||||
System = command.System?.ToString(),
|
||||
command.Description,
|
||||
command.Format,
|
||||
DurationMinutes = command.DurationMinutes,
|
||||
IsOneShot = command.IsOneShot,
|
||||
CoverImageUrl = command.ImageReference
|
||||
},
|
||||
transaction);
|
||||
|
||||
@@ -137,6 +144,7 @@ public sealed class CreateSessionHandler(
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
|
||||
@@ -150,7 +158,10 @@ public sealed class CreateSessionHandler(
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace GmRelay.Shared.Features.Showcase;
|
||||
|
||||
public sealed record ShowcaseFilter(
|
||||
DateFilter Date = DateFilter.All,
|
||||
SeatFilter Seats = SeatFilter.Any,
|
||||
string? System = null,
|
||||
bool? IsOneShot = null,
|
||||
string? Format = null);
|
||||
|
||||
public enum DateFilter
|
||||
{
|
||||
Today,
|
||||
Tomorrow,
|
||||
ThisWeek,
|
||||
All
|
||||
}
|
||||
|
||||
public enum SeatFilter
|
||||
{
|
||||
Available,
|
||||
Waitlist,
|
||||
Any
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace GmRelay.Shared.Features.Showcase;
|
||||
|
||||
public sealed record ShowcaseSessionDto(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
@@ -73,7 +73,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.3.0</div>
|
||||
<div class="nav-version">v3.5.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@attribute [Authorize]
|
||||
@inject ISessionStore SessionStore
|
||||
@inject AuthorizedSessionService AuthorizedSessionService
|
||||
@inject IConfiguration Configuration
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -12,6 +13,65 @@
|
||||
<div class="profile-container">
|
||||
<h1 class="page-title">Профиль</h1>
|
||||
|
||||
@if (masterProfile is not null)
|
||||
{
|
||||
<div class="profile-card master-profile-card">
|
||||
<div class="profile-card-header">
|
||||
<div>
|
||||
<h2 class="section-title">Публичный профиль мастера</h2>
|
||||
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||
</div>
|
||||
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
|
||||
<div class="gm-form-group public-toggle-field">
|
||||
<label class="gm-checkbox-label">
|
||||
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
|
||||
<span>Опубликовать профиль</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="profile-form-grid">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя в публичном профиле</label>
|
||||
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Описание</label>
|
||||
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
|
||||
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
|
||||
</button>
|
||||
@if (PublicMasterProfileUrl is not null)
|
||||
{
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||
Открыть публичный профиль
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (PublicMasterProfileUrl is not null)
|
||||
{
|
||||
<div class="public-link-row">
|
||||
<span>Ссылка профиля</span>
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<p class="loading-text">Загрузка...</p>
|
||||
@@ -92,11 +152,14 @@
|
||||
|
||||
@code {
|
||||
private List<LinkedIdentity>? identities;
|
||||
private MasterProfileSettings? masterProfile;
|
||||
private string? currentPlatform;
|
||||
private string? currentExternalUserId;
|
||||
private bool isUnlinking;
|
||||
private bool savingMasterProfile;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private MasterProfileEditModel masterProfileModel = new();
|
||||
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
@@ -131,6 +194,7 @@
|
||||
}
|
||||
|
||||
await LoadIdentities();
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
|
||||
private async Task LoadIdentities()
|
||||
@@ -152,6 +216,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMasterProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
|
||||
if (masterProfile is not null)
|
||||
{
|
||||
masterProfileModel = new MasterProfileEditModel
|
||||
{
|
||||
DisplayName = masterProfile.DisplayName,
|
||||
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
|
||||
IsPublic = masterProfile.IsPublic,
|
||||
Bio = masterProfile.Bio ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private string? PublicMasterProfileUrl =>
|
||||
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
|
||||
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
|
||||
: null;
|
||||
|
||||
private async Task SaveMasterProfile()
|
||||
{
|
||||
savingMasterProfile = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
|
||||
masterProfileModel.PublicSlug,
|
||||
masterProfileModel.IsPublic,
|
||||
masterProfileModel.DisplayName,
|
||||
masterProfileModel.Bio);
|
||||
|
||||
successMessage = "Публичный профиль мастера обновлён.";
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingMasterProfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasLinkedPlatform(string platform)
|
||||
{
|
||||
return identities?.Any(i => i.Platform == platform) ?? false;
|
||||
@@ -188,4 +306,12 @@
|
||||
isUnlinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MasterProfileEditModel
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string PublicSlug { get; set; } = string.Empty;
|
||||
public bool IsPublic { get; set; }
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ else if (club is not null)
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
|
||||
{
|
||||
<div class="public-share-row">
|
||||
<span>Мастер</span>
|
||||
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
|
||||
@(club.MasterDisplayName ?? "Профиль мастера")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (club.Sessions.Count == 0)
|
||||
@@ -92,6 +101,8 @@ else if (club is not null)
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
@page "/gm/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && profile is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Профиль мастера не найден</h1>
|
||||
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (profile is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact master-profile-hero">
|
||||
<span class="status-badge status-success">Мастер</span>
|
||||
<h1>@profile.DisplayName</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(profile.Bio))
|
||||
{
|
||||
<p>@profile.Bio</p>
|
||||
}
|
||||
<div class="public-share-row">
|
||||
<span>Ссылка профиля</span>
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (profile.Clubs.Count > 0)
|
||||
{
|
||||
<section class="glass-card master-profile-section">
|
||||
<h2>Клубы</h2>
|
||||
<div class="master-profile-club-list">
|
||||
@foreach (var club in profile.Clubs)
|
||||
{
|
||||
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (profile.Sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Опубликованных игр пока нет</h2>
|
||||
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in profile.Sessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.GroupName</span>
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private GmRelay.Web.Services.PublicMasterProfile? profile;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
|
||||
|
||||
private string PublicMasterProfileUrl =>
|
||||
profile is null
|
||||
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
|
||||
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
profile = string.IsNullOrWhiteSpace(Slug)
|
||||
? null
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
SessionStatus.Planned => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@using GmRelay.Shared.Features.Showcase
|
||||
@using GmRelay.Web.Services
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@@ -30,10 +33,36 @@ else if (session is not null)
|
||||
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
|
||||
{
|
||||
<div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
|
||||
}
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h1>@session.Title</h1>
|
||||
<p>@session.GroupName</p>
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Мастер</span>
|
||||
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||
</div>
|
||||
}
|
||||
<div class="session-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(session.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
|
||||
}
|
||||
@if (session.IsOneShot)
|
||||
{
|
||||
<span class="status-badge status-warning">Ваншот</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(session.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="glass-card public-session-detail">
|
||||
@@ -50,14 +79,51 @@ else if (session is not null)
|
||||
<span>Статус</span>
|
||||
<strong>@TranslateStatus(session.Status)</strong>
|
||||
</div>
|
||||
@if (session.DurationMinutes.HasValue)
|
||||
{
|
||||
<div>
|
||||
<span>Длительность</span>
|
||||
<strong>@FormatDuration(session.DurationMinutes.Value)</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(session.Description))
|
||||
{
|
||||
<div class="session-description">
|
||||
<h3>Описание</h3>
|
||||
<p>@session.Description</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (registrationResult is not null)
|
||||
{
|
||||
<div class="glass-card @GetRegistrationResultClass()">
|
||||
<p>@registrationResult</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="public-settings-actions">
|
||||
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<a class="btn-gm btn-gm-outline" href="@MasterProfilePath(session.MasterProfileSlug)">Мастер</a>
|
||||
}
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||
@if (session.AllowDirectRegistration)
|
||||
{
|
||||
@if (isAuthenticated)
|
||||
{
|
||||
<button class="btn-gm btn-gm-primary" @onclick="RegisterAsync">Записаться</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти, чтобы записаться</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
@@ -65,21 +131,66 @@ else if (session is not null)
|
||||
@code {
|
||||
[Parameter] public Guid SessionId { get; set; }
|
||||
|
||||
private WebPublicSession? session;
|
||||
private ShowcaseSessionDto? session;
|
||||
private bool loaded;
|
||||
private bool isAuthenticated;
|
||||
private string? registrationResult;
|
||||
|
||||
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
|
||||
|
||||
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
session = await SessionStore.GetPublicSessionAsync(SessionId);
|
||||
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 static string FormatSeats(WebPublicSession session)
|
||||
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}"
|
||||
@@ -90,6 +201,35 @@ else if (session is not null)
|
||||
: 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",
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
@page "/showcase"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
@using GmRelay.Shared.Features.Showcase
|
||||
|
||||
<PageTitle>Каталог игр — GM-Relay</PageTitle>
|
||||
|
||||
<HeadContent>
|
||||
<meta name="description" content="Каталог настольных ролевых игр GM-Relay. Найдите игру по душе — ваншоты, кампании, онлайн и офлайн." />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero">
|
||||
<h1>Каталог игр</h1>
|
||||
<p>Найдите настольную ролевую игру по душе — ваншоты, кампании, онлайн и офлайн.</p>
|
||||
</section>
|
||||
|
||||
<section class="glass-card showcase-filters">
|
||||
<div class="showcase-filter-group">
|
||||
<span class="showcase-filter-label">Когда</span>
|
||||
<div class="showcase-filter-buttons">
|
||||
<button class="btn-gm @(filter.Date == DateFilter.Today ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Today)">Сегодня</button>
|
||||
<button class="btn-gm @(filter.Date == DateFilter.Tomorrow ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Tomorrow)">Завтра</button>
|
||||
<button class="btn-gm @(filter.Date == DateFilter.ThisWeek ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.ThisWeek)">На этой неделе</button>
|
||||
<button class="btn-gm @(filter.Date == DateFilter.All ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.All)">Все</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-filter-group">
|
||||
<span class="showcase-filter-label">Места</span>
|
||||
<div class="showcase-filter-buttons">
|
||||
<button class="btn-gm @(filter.Seats == SeatFilter.Available ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Available)">Есть места</button>
|
||||
<button class="btn-gm @(filter.Seats == SeatFilter.Waitlist ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Waitlist)">Лист ожидания</button>
|
||||
<button class="btn-gm @(filter.Seats == SeatFilter.Any ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Any)">Любые</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-filter-group">
|
||||
<label class="showcase-filter-label" for="system-filter">Система</label>
|
||||
<select id="system-filter" class="gm-form-control showcase-filter-select" aria-label="Система" @onchange="OnSystemChanged">
|
||||
<option value="" selected="@(filter.System is null)">Любая</option>
|
||||
@foreach (var system in Enum.GetValues<GameSystem>())
|
||||
{
|
||||
var name = system.ToString();
|
||||
<option value="@name" selected="@(filter.System == name)">@system.ToDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="showcase-filter-group">
|
||||
<span class="showcase-filter-label">Тип</span>
|
||||
<div class="showcase-filter-buttons">
|
||||
<button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(true)">Ваншот</button>
|
||||
<button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(false)">Кампания</button>
|
||||
<button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(null)">Любое</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-filter-group">
|
||||
<span class="showcase-filter-label">Формат</span>
|
||||
<div class="showcase-filter-buttons">
|
||||
<button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Online"))">Онлайн</button>
|
||||
<button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Offline"))">Офлайн</button>
|
||||
<button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Hybrid"))">Гибрид</button>
|
||||
<button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat((string?)null))">Любой</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (loading && sessions.Count == 0)
|
||||
{
|
||||
<div class="showcase-grid">
|
||||
@for (var i = 0; i < 6; i++)
|
||||
{
|
||||
<div class="glass-card showcase-card showcase-skeleton">
|
||||
<div class="skeleton showcase-skeleton-image"></div>
|
||||
<div class="showcase-card-body">
|
||||
<div class="skeleton skeleton-text" style="width: 70%;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 45%;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 55%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (!loading && sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Игры не найдены</h2>
|
||||
<p>Попробуйте изменить фильтры или загляните позже — новые сессии появляются каждый день.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="showcase-grid">
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
<article class="glass-card showcase-card animate-fade-in">
|
||||
<div class="showcase-card-image"
|
||||
style="@(string.IsNullOrWhiteSpace(session.CoverImageUrl)
|
||||
? $"background: {GetGradientStyle(session.Id)}; background-size: cover; background-position: center;"
|
||||
: $"background-image: url({session.CoverImageUrl}); background-size: cover; background-position: center;")">
|
||||
</div>
|
||||
<div class="showcase-card-body">
|
||||
<div class="showcase-card-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(session.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
|
||||
}
|
||||
@if (session.IsOneShot)
|
||||
{
|
||||
<span class="status-badge status-warning">Ваншот</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(session.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
|
||||
}
|
||||
</div>
|
||||
<h2 class="showcase-card-title">@session.Title</h2>
|
||||
<div class="showcase-card-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
@if (session.DurationMinutes.HasValue)
|
||||
{
|
||||
<span>@FormatDuration(session.DurationMinutes.Value)</span>
|
||||
}
|
||||
</div>
|
||||
<div class="showcase-card-seats">
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
<div class="showcase-card-club">
|
||||
<span>@session.GroupName</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<div class="showcase-card-master">
|
||||
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||
</div>
|
||||
}
|
||||
<div class="showcase-card-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
|
||||
@if (session.AllowDirectRegistration)
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (hasMore)
|
||||
{
|
||||
<div class="showcase-load-more">
|
||||
<button class="btn-gm btn-gm-primary" @onclick="LoadMoreAsync" disabled="@loading">
|
||||
@if (loading)
|
||||
{
|
||||
<span>Загрузка...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Загрузить ещё</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<style>
|
||||
.showcase-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.showcase-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.showcase-filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: 'Jura', sans-serif;
|
||||
}
|
||||
|
||||
.showcase-filter-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.showcase-filter-buttons .btn-gm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.showcase-filter-select {
|
||||
min-width: 180px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.showcase-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@media (min-width: 640px) {
|
||||
.showcase-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@media (min-width: 1024px) {
|
||||
.showcase-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.showcase-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.showcase-card-image {
|
||||
height: 160px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.showcase-card-body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.showcase-card-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.showcase-card-title {
|
||||
font-size: 1.0625rem;
|
||||
margin: 0;
|
||||
font-family: 'Cinzel', serif;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.showcase-card-meta,
|
||||
.showcase-card-seats,
|
||||
.showcase-card-club,
|
||||
.showcase-card-master {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
}
|
||||
|
||||
.showcase-card-club {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.showcase-card-master a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.showcase-card-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.showcase-card-actions .btn-gm {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showcase-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.showcase-skeleton {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.showcase-skeleton-image {
|
||||
height: 160px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.showcase-skeleton .showcase-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.showcase-skeleton .skeleton-text {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private ShowcaseFilter filter = new();
|
||||
private List<ShowcaseSessionDto> sessions = new();
|
||||
private bool loading;
|
||||
private bool hasMore;
|
||||
private int page = 1;
|
||||
private const int PageSize = 12;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
loading = true;
|
||||
try
|
||||
{
|
||||
page = 1;
|
||||
sessions.Clear();
|
||||
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
|
||||
sessions.AddRange(results);
|
||||
hasMore = results.Count == PageSize;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMoreAsync()
|
||||
{
|
||||
if (loading)
|
||||
return;
|
||||
|
||||
loading = true;
|
||||
try
|
||||
{
|
||||
page++;
|
||||
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
|
||||
sessions.AddRange(results);
|
||||
hasMore = results.Count == PageSize;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnFilterChanged()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task SetDate(DateFilter value)
|
||||
{
|
||||
filter = filter with { Date = value };
|
||||
await OnFilterChanged();
|
||||
}
|
||||
|
||||
private async Task SetSeats(SeatFilter value)
|
||||
{
|
||||
filter = filter with { Seats = value };
|
||||
await OnFilterChanged();
|
||||
}
|
||||
|
||||
private async Task OnSystemChanged(ChangeEventArgs e)
|
||||
{
|
||||
var value = e.Value?.ToString();
|
||||
filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value };
|
||||
await OnFilterChanged();
|
||||
}
|
||||
|
||||
private async Task SetOneShot(bool? value)
|
||||
{
|
||||
filter = filter with { IsOneShot = value };
|
||||
await OnFilterChanged();
|
||||
}
|
||||
|
||||
private async Task SetFormat(string? value)
|
||||
{
|
||||
filter = filter with { Format = value };
|
||||
await OnFilterChanged();
|
||||
}
|
||||
|
||||
private static string GetGradientStyle(Guid id)
|
||||
{
|
||||
var bytes = id.ToByteArray();
|
||||
var hue1 = bytes[0] % 360;
|
||||
var hue2 = (bytes[1] + 120) % 360;
|
||||
return $"linear-gradient(135deg, hsl({hue1}, 55%, 28%) 0%, hsl({hue2}, 55%, 20%) 100%)";
|
||||
}
|
||||
|
||||
private static string GetSystemDisplayName(string? system)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(system))
|
||||
return system ?? string.Empty;
|
||||
|
||||
if (Enum.TryParse<GameSystem>(system, out var gs))
|
||||
return gs.ToDisplayName();
|
||||
|
||||
return system;
|
||||
}
|
||||
|
||||
private static string FormatSeats(ShowcaseSessionDto session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
if (session.WaitlistedPlayerCount > 0)
|
||||
seats += $", ожидание {session.WaitlistedPlayerCount}";
|
||||
|
||||
return seats;
|
||||
}
|
||||
|
||||
private static string FormatDuration(int minutes)
|
||||
{
|
||||
if (minutes < 60)
|
||||
return $"{minutes} мин";
|
||||
|
||||
var hours = minutes / 60;
|
||||
var mins = minutes % 60;
|
||||
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
|
||||
}
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
"Offline" => "Офлайн",
|
||||
"Hybrid" => "Гибрид",
|
||||
_ => format
|
||||
};
|
||||
}
|
||||
@@ -161,6 +161,7 @@ app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService aut
|
||||
app.MapPost("/auth/telegram-webapp", async (
|
||||
HttpContext context,
|
||||
TelegramAuthService authService,
|
||||
ISessionStore sessionStore,
|
||||
TelegramWebAppAuthRequest request) =>
|
||||
{
|
||||
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
|
||||
@@ -168,6 +169,8 @@ app.MapPost("/auth/telegram-webapp", async (
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
|
||||
|
||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||
await context.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
@@ -180,6 +183,7 @@ app.MapPost("/auth/telegram-webapp", async (
|
||||
app.MapPost("/auth/telegram-login", async (
|
||||
HttpContext context,
|
||||
TelegramAuthService authService,
|
||||
ISessionStore sessionStore,
|
||||
TelegramLoginPayload request) =>
|
||||
{
|
||||
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
|
||||
@@ -187,6 +191,8 @@ app.MapPost("/auth/telegram-login", async (
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
|
||||
|
||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||
await context.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
|
||||
@@ -90,6 +90,52 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
|
||||
}
|
||||
|
||||
public Task<MasterProfileSettings?> GetMasterProfileSettingsForCurrentUserAsync()
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return Task.FromResult<MasterProfileSettings?>(null);
|
||||
|
||||
return sessionStore.GetMasterProfileSettingsAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
public async Task UpdateMasterProfileSettingsForCurrentUserAsync(
|
||||
string? publicSlug,
|
||||
bool isPublic,
|
||||
string displayName,
|
||||
string? bio)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var normalizedDisplayName = displayName.Trim();
|
||||
if (normalizedDisplayName.Length is < 2 or > 120)
|
||||
{
|
||||
throw new InvalidOperationException("Имя профиля должно быть от 2 до 120 символов.");
|
||||
}
|
||||
|
||||
var normalizedBio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim();
|
||||
if (normalizedBio?.Length > 1200)
|
||||
{
|
||||
throw new InvalidOperationException("Описание профиля должно быть не длиннее 1200 символов.");
|
||||
}
|
||||
|
||||
var normalizedSlug = NormalizeMasterProfileSlug(publicSlug);
|
||||
if (isPublic && normalizedSlug is null)
|
||||
{
|
||||
throw new InvalidOperationException("Для публичного профиля нужен короткий адрес.");
|
||||
}
|
||||
|
||||
await sessionStore.UpdateMasterProfileSettingsAsync(
|
||||
identity.Value.Platform,
|
||||
identity.Value.ExternalUserId,
|
||||
normalizedSlug,
|
||||
isPublic,
|
||||
normalizedDisplayName,
|
||||
normalizedBio);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
@@ -472,4 +518,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
private static string? NormalizeMasterProfileSlug(string? publicSlug) => NormalizePublicSlug(publicSlug);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Showcase;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
@@ -41,12 +42,35 @@ public sealed record WebPublicSession(
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
int WaitlistedPlayerCount,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<WebPublicSession> Sessions,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
public sealed record MasterProfileSettings(
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
string? PublicSlug,
|
||||
bool IsPublic,
|
||||
string? Bio);
|
||||
|
||||
public sealed record PublicMasterClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug);
|
||||
|
||||
public sealed record PublicMasterProfile(
|
||||
string Slug,
|
||||
string DisplayName,
|
||||
string? Bio,
|
||||
IReadOnlyList<PublicMasterClub> Clubs,
|
||||
IReadOnlyList<WebPublicSession> Sessions);
|
||||
|
||||
public interface ISessionStore
|
||||
@@ -84,6 +108,9 @@ public interface ISessionStore
|
||||
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
|
||||
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
|
||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
|
||||
|
||||
// --- Identity linking (issue #35) ---
|
||||
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
||||
@@ -91,6 +118,11 @@ public interface ISessionStore
|
||||
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
|
||||
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
|
||||
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
|
||||
|
||||
// --- Showcase / game catalog (issue #39) ---
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||
}
|
||||
|
||||
public sealed record LinkedIdentity(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Showcase;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
@@ -109,7 +110,33 @@ internal sealed record WebBatchSessionRow(
|
||||
bool TopicCreatedByBot = false);
|
||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
||||
internal sealed record WebPublicGroupRow(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug,
|
||||
string? MasterProfileSlug,
|
||||
string? MasterDisplayName);
|
||||
internal sealed record ShowcaseSessionRow(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string? MasterProfileSlug,
|
||||
string? MasterDisplayName);
|
||||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -284,7 +311,9 @@ public sealed class SessionService(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||
g.public_slug AS Slug
|
||||
g.public_slug AS Slug,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM game_groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
@@ -293,11 +322,23 @@ public sealed class SessionService(
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gm.player_id
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||||
AND mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND lower(g.public_slug) = lower(@Slug)
|
||||
""",
|
||||
new { Slug = slug });
|
||||
new { Slug = slug, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
|
||||
if (group is null)
|
||||
{
|
||||
@@ -305,7 +346,7 @@ public sealed class SessionService(
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
@@ -322,7 +363,9 @@ public sealed class SessionService(
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -346,6 +389,18 @@ public sealed class SessionService(
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gm.player_id
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||||
AND mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
@@ -358,10 +413,263 @@ public sealed class SessionService(
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.QueryAsync<ShowcaseSessionRow>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.system AS System,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.format AS Format,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.cover_image_url AS CoverImageUrl,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.allow_direct_registration AS AllowDirectRegistration,
|
||||
s.description AS Description,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gm.player_id
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||||
AND mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
@DateFilter = 'All'
|
||||
OR (@DateFilter = 'Today' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '1 day')
|
||||
OR (@DateFilter = 'Tomorrow' AND s.scheduled_at >= CURRENT_DATE + interval '1 day' AND s.scheduled_at < CURRENT_DATE + interval '2 days')
|
||||
OR (@DateFilter = 'ThisWeek' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '7 days')
|
||||
)
|
||||
AND (
|
||||
@SeatFilter = 'Any'
|
||||
OR (@SeatFilter = 'Available' AND (s.max_players IS NULL OR active_counts.count < s.max_players))
|
||||
OR (@SeatFilter = 'Waitlist' AND (s.max_players IS NOT NULL AND active_counts.count >= s.max_players))
|
||||
)
|
||||
AND (@System IS NULL OR s.system = @System)
|
||||
AND (@IsOneShot IS NULL OR s.is_one_shot = @IsOneShot)
|
||||
AND (@Format IS NULL OR s.format = @Format)
|
||||
ORDER BY s.scheduled_at ASC
|
||||
LIMIT @PageSize OFFSET @Offset
|
||||
""",
|
||||
new
|
||||
{
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
DateFilter = filter.Date.ToString(),
|
||||
SeatFilter = filter.Seats.ToString(),
|
||||
filter.System,
|
||||
filter.IsOneShot,
|
||||
filter.Format,
|
||||
PageSize = pageSize,
|
||||
Offset = (page - 1) * pageSize,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
});
|
||||
|
||||
return rows.Select(r => new ShowcaseSessionDto(
|
||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.system AS System,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.format AS Format,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.cover_image_url AS CoverImageUrl,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.allow_direct_registration AS AllowDirectRegistration,
|
||||
s.description AS Description,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gm.player_id
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||||
AND mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
});
|
||||
|
||||
if (row is null)
|
||||
return null;
|
||||
|
||||
return new ShowcaseSessionDto(
|
||||
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
||||
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
|
||||
var session = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||
"""
|
||||
SELECT s.id, s.max_players AS MaxPlayers, s.allow_direct_registration AS AllowDirectRegistration
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.is_public = true
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled },
|
||||
transaction);
|
||||
|
||||
if (session is null || !(bool)session.allowdirectregistration)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction);
|
||||
|
||||
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
|
||||
(int?)session.maxplayers,
|
||||
await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*) FROM session_participants
|
||||
WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction));
|
||||
|
||||
var inserted = await conn.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
|
||||
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
|
||||
ON CONFLICT (session_id, player_id) DO NOTHING
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
PlayerId = playerId,
|
||||
Pending = RsvpStatus.Pending,
|
||||
RegistrationStatus = registrationStatus
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (inserted == 0)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
@@ -1581,6 +1889,182 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
if (effectiveId is null)
|
||||
return null;
|
||||
|
||||
return await conn.QuerySingleOrDefaultAsync<MasterProfileSettings>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId,
|
||||
COALESCE(mp.display_name, p.display_name) AS DisplayName,
|
||||
mp.public_slug AS PublicSlug,
|
||||
COALESCE(mp.is_public, false) AS IsPublic,
|
||||
mp.bio AS Bio
|
||||
FROM players p
|
||||
LEFT JOIN master_profiles mp ON mp.player_id = p.id
|
||||
WHERE p.id = @PlayerId
|
||||
""",
|
||||
new { PlayerId = effectiveId.Value });
|
||||
}
|
||||
|
||||
public async Task UpdateMasterProfileSettingsAsync(
|
||||
string platform,
|
||||
string externalUserId,
|
||||
string? publicSlug,
|
||||
bool isPublic,
|
||||
string displayName,
|
||||
string? bio)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
if (effectiveId is null)
|
||||
throw new InvalidOperationException("Current player not found.");
|
||||
|
||||
try
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO master_profiles (player_id, public_slug, is_public, display_name, bio)
|
||||
VALUES (@PlayerId, @PublicSlug, @IsPublic, @DisplayName, @Bio)
|
||||
ON CONFLICT (player_id) DO UPDATE
|
||||
SET public_slug = EXCLUDED.public_slug,
|
||||
is_public = EXCLUDED.is_public,
|
||||
display_name = EXCLUDED.display_name,
|
||||
bio = EXCLUDED.bio,
|
||||
updated_at = now()
|
||||
""",
|
||||
new
|
||||
{
|
||||
PlayerId = effectiveId.Value,
|
||||
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
|
||||
IsPublic = isPublic,
|
||||
DisplayName = displayName,
|
||||
Bio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim()
|
||||
});
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
throw new InvalidOperationException("Master profile slug is already in use.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||||
"""
|
||||
SELECT mp.player_id AS PlayerId,
|
||||
mp.public_slug AS Slug,
|
||||
mp.display_name AS DisplayName,
|
||||
mp.bio AS Bio
|
||||
FROM master_profiles mp
|
||||
WHERE mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
AND lower(mp.public_slug) = lower(@Slug)
|
||||
""",
|
||||
new { Slug = slug });
|
||||
|
||||
if (profile is null)
|
||||
return null;
|
||||
|
||||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
|
||||
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||||
}
|
||||
|
||||
private static async Task<List<PublicMasterClub>> GetPublicClubsForMasterAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid playerId)
|
||||
{
|
||||
return (await conn.QueryAsync<PublicMasterClub>(
|
||||
"""
|
||||
SELECT DISTINCT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||
g.public_slug AS Slug
|
||||
FROM game_groups g
|
||||
JOIN group_managers gm ON gm.group_id = g.id
|
||||
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
WHERE COALESCE(manager_link.primary_player_id, gm.player_id) = @PlayerId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
ORDER BY Name
|
||||
""",
|
||||
new { PlayerId = playerId })).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid playerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT DISTINCT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
JOIN group_managers gm ON gm.group_id = g.id
|
||||
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
|
||||
JOIN master_profiles mp ON mp.player_id = COALESCE(manager_link.primary_player_id, gm.player_id)
|
||||
AND mp.player_id = @PlayerId
|
||||
AND mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
@@ -1596,7 +2080,9 @@ public sealed class SessionService(
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -1620,6 +2106,18 @@ public sealed class SessionService(
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gm.player_id
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||||
AND mp.is_public = true
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
@@ -1633,7 +2131,8 @@ public sealed class SessionService(
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -841,6 +841,58 @@ select option {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.profile-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.profile-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.master-profile-bio {
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.master-profile-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.master-profile-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
}
|
||||
|
||||
.master-profile-club-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.master-profile-club-list a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.public-master-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
}
|
||||
|
||||
.public-master-link a {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* === Campaign templates === */
|
||||
.campaign-template-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -1795,6 +1847,24 @@ body.telegram-mini-app .session-card-mobile {
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
@@ -61,8 +62,9 @@ public sealed class DiscordProjectStructureTests
|
||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
var version = GetProjectVersion(repoRoot);
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:3.3.0", compose);
|
||||
Assert.Contains($"gmrelay-discord-bot:{version}", compose);
|
||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
@@ -75,14 +77,15 @@ public sealed class DiscordProjectStructureTests
|
||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var version = GetProjectVersion(repoRoot);
|
||||
|
||||
Assert.Contains("<Version>3.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"<Version>{version}</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains($"VERSION: {version}", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains($"gmrelay-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"gmrelay-web:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"gmrelay-discord-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v3.3.0",
|
||||
$"v{version}",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
@@ -121,4 +124,13 @@ public sealed class DiscordProjectStructureTests
|
||||
Assert.Contains("test:", discordBlock);
|
||||
Assert.Contains("localhost:8082/health", discordBlock);
|
||||
}
|
||||
|
||||
private static string GetProjectVersion(string repoRoot)
|
||||
{
|
||||
var props = XDocument.Load(Path.Combine(repoRoot, "Directory.Build.props"));
|
||||
return props.Root?
|
||||
.Element("PropertyGroup")?
|
||||
.Element("Version")?
|
||||
.Value ?? throw new InvalidOperationException("Version not found.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Domain;
|
||||
|
||||
public sealed class GameSystemTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Dnd5e", GameSystem.Dnd5e)]
|
||||
[InlineData("D&D", GameSystem.Dnd5e)]
|
||||
[InlineData("dnd5e", GameSystem.Dnd5e)]
|
||||
[InlineData(" dnd5e ", GameSystem.Dnd5e)]
|
||||
[InlineData("D&D 5e", GameSystem.Dnd5e)]
|
||||
[InlineData("pathfinder", GameSystem.Pathfinder2e)]
|
||||
[InlineData("call of cthulhu", GameSystem.CallOfCthulhu7e)]
|
||||
[InlineData("shadow", GameSystem.Shadowdark)]
|
||||
[InlineData("dark", GameSystem.Shadowdark)]
|
||||
[InlineData("unknown xyz", GameSystem.Other)]
|
||||
public void TryParseFuzzy_ShouldMapInputToExpectedSystem(string input, GameSystem expected)
|
||||
{
|
||||
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("днд")]
|
||||
[InlineData("колова")]
|
||||
public void TryParseFuzzy_ShouldReturnOtherForUnmatchedCyrillicInput(string input)
|
||||
{
|
||||
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||
|
||||
Assert.Equal(GameSystem.Other, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseFuzzy_ShouldReturnNullForNullInput()
|
||||
{
|
||||
var result = GameSystemExtensions.TryParseFuzzy(null!);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TryParseFuzzy_ShouldReturnNullForEmptyOrWhitespaceInput(string input)
|
||||
{
|
||||
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GameSystem.Dnd5e, "D&D 5e")]
|
||||
[InlineData(GameSystem.Other, "Другое")]
|
||||
[InlineData(GameSystem.Pathfinder2e, "Pathfinder 2e")]
|
||||
[InlineData(GameSystem.Shadowdark, "Shadowdark")]
|
||||
[InlineData((GameSystem)999, "Другое")]
|
||||
public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected)
|
||||
{
|
||||
var result = system.ToDisplayName();
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,30 @@ public sealed class SessionCapacityRulesTests
|
||||
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideJoinStatus_ShouldReturnActive_WhenUnlimitedSeats()
|
||||
{
|
||||
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5);
|
||||
|
||||
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideJoinStatus_ShouldReturnWaitlisted_WhenOverCapacity()
|
||||
{
|
||||
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5);
|
||||
|
||||
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideJoinStatus_ShouldReturnActive_WhenZeroActiveAndPositiveMax()
|
||||
{
|
||||
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0);
|
||||
|
||||
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using GmRelay.Web.Services;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Showcase;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
@@ -827,6 +828,14 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Guid? LastUpdatedPublicGroupId { get; private set; }
|
||||
public string? LastUpdatedPublicSlug { get; private set; }
|
||||
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
|
||||
public MasterProfileSettings? MasterProfileSettings { get; set; } = new(Guid.NewGuid(), "Owner GM", null, false, null);
|
||||
public bool UpdateMasterProfileCalled { get; private set; }
|
||||
public string? LastMasterProfilePlatform { get; private set; }
|
||||
public string? LastMasterProfileExternalUserId { get; private set; }
|
||||
public string? LastMasterProfileSlug { get; private set; }
|
||||
public bool? LastMasterProfileIsPublic { get; private set; }
|
||||
public string? LastMasterProfileDisplayName { get; private set; }
|
||||
public string? LastMasterProfileBio { get; private set; }
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
@@ -1194,6 +1203,25 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult(MasterProfileSettings);
|
||||
|
||||
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio)
|
||||
{
|
||||
UpdateMasterProfileCalled = true;
|
||||
LastMasterProfilePlatform = platform;
|
||||
LastMasterProfileExternalUserId = externalUserId;
|
||||
LastMasterProfileSlug = publicSlug;
|
||||
LastMasterProfileIsPublic = isPublic;
|
||||
LastMasterProfileDisplayName = displayName;
|
||||
LastMasterProfileBio = bio;
|
||||
MasterProfileSettings = new(Guid.NewGuid(), displayName, publicSlug, isPublic, bio);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) =>
|
||||
Task.FromResult<PublicMasterProfile?>(null);
|
||||
|
||||
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult<Guid?>(Guid.NewGuid());
|
||||
|
||||
@@ -1209,6 +1237,15 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) =>
|
||||
Task.FromResult<IReadOnlyList<ShowcaseSessionDto>>([]);
|
||||
|
||||
public Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) =>
|
||||
Task.FromResult<ShowcaseSessionDto?>(null);
|
||||
|
||||
public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
private bool IsManager(Guid groupId, long telegramId) =>
|
||||
IsOwner(groupId, telegramId) ||
|
||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class MasterProfilesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationV028_ShouldAddMasterProfilesWithoutExternalIdentifiers()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql");
|
||||
|
||||
Assert.Contains("CREATE TABLE master_profiles", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("player_id", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("is_public", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("display_name", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("bio", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_master_profiles_public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("external_user_id", migration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("telegram_id", migration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("discord", migration, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStore_ShouldExposeSanitizedMasterProfileContracts()
|
||||
{
|
||||
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
||||
|
||||
Assert.Contains("MasterProfileSettings", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicMasterProfile", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicMasterClub", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("UpdateMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicMasterProfileBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||
|
||||
var publicProfileSection = RecordSection(sessionStore, "PublicMasterProfile");
|
||||
Assert.DoesNotContain("AvatarUrl", publicProfileSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ExternalUserId", publicProfileSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramId", publicProfileSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DiscordId", publicProfileSection, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicMasterProfilePage_ShouldBePublicAndHideTechnicalIdentityData()
|
||||
{
|
||||
var publicProfilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor");
|
||||
|
||||
Assert.Contains("@page \"/gm/{Slug}\"", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicMasterProfileBySlugAsync", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ExternalUserId", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramId", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DiscordId", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("AvatarUrl", publicProfilePage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("LinkedIdentity", publicProfilePage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProfilePage_ShouldManageMasterProfilePublication()
|
||||
{
|
||||
var profilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Profile.razor");
|
||||
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||
|
||||
Assert.Contains("GetMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("UpdateMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("masterProfileModel", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicMasterProfileUrl", profilePage, StringComparison.Ordinal);
|
||||
Assert.Contains("NormalizeMasterProfileSlug", authorizedService, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TelegramLoginEndpoints_ShouldUpsertPlayersForProfileManagement()
|
||||
{
|
||||
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
|
||||
|
||||
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
|
||||
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
|
||||
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
|
||||
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicGamePages_ShouldLinkPublishedMasterProfilesWithoutPrivateIds()
|
||||
{
|
||||
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
|
||||
var showcasePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Showcase.razor");
|
||||
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("MasterProfileSlug", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("MasterProfileSlug", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.Contains("MasterProfileSlug", showcasePage, StringComparison.Ordinal);
|
||||
Assert.Contains("master_profiles", sessionService, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("targetExternalUserId", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("target_platform", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicMasterProfileQueries_ShouldIncludeCoGmManagedPublishedGames()
|
||||
{
|
||||
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
var clubsQuery = MethodSection(sessionService, "GetPublicClubsForMasterAsync");
|
||||
var sessionsQuery = MethodSection(sessionService, "GetPublicSessionsForMasterAsync");
|
||||
|
||||
Assert.Contains("JOIN group_managers gm", clubsQuery, StringComparison.Ordinal);
|
||||
Assert.Contains("JOIN group_managers gm", sessionsQuery, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gm.role = @OwnerRole", clubsQuery, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gm.role = @OwnerRole", sessionsQuery, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string RecordSection(string source, string recordName)
|
||||
{
|
||||
var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf(");", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..(end + 2)];
|
||||
}
|
||||
|
||||
private static string PublicQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..end];
|
||||
}
|
||||
|
||||
private static string MethodSection(string source, string methodName)
|
||||
{
|
||||
var start = -1;
|
||||
var searchFrom = 0;
|
||||
while (searchFrom < source.Length)
|
||||
{
|
||||
var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal);
|
||||
if (candidate < 0)
|
||||
return string.Empty;
|
||||
|
||||
var lineStart = source.LastIndexOf('\n', candidate);
|
||||
var headerStart = lineStart < 0 ? 0 : lineStart + 1;
|
||||
var header = source[headerStart..candidate];
|
||||
if (header.Contains("private static async Task", StringComparison.Ordinal))
|
||||
{
|
||||
start = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
searchFrom = candidate + methodName.Length;
|
||||
}
|
||||
|
||||
var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal);
|
||||
if (nextMethod < 0)
|
||||
{
|
||||
nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return nextMethod < 0 ? source[start..] : source[start..nextMethod];
|
||||
}
|
||||
|
||||
private static string EndpointSection(string source, string route)
|
||||
{
|
||||
var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal);
|
||||
return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint];
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user