Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 992f71c0e4 | |||
| 21e29564f6 | |||
| 401653a4d1 | |||
| e970e94e00 | |||
| 242ff99a83 | |||
| f2c9f34ab4 | |||
| e5945288ac | |||
| 7d1489445e | |||
| 4af4e52778 | |||
| a20da4b1a0 | |||
| edf40c9a09 | |||
| 1a8161027c | |||
| 85918c1e5d | |||
| ea714480d3 | |||
| 1d62f69ff0 | |||
| d762ecc377 | |||
| a28b75dd5b | |||
| 2b725708ef | |||
| da0a306340 | |||
| f493836b77 | |||
| 6e7a0cb493 | |||
| 76b3ff7ddf | |||
| 536061f63c | |||
| f7a12d14d2 | |||
| 3c1a98bcc4 | |||
| d591e5ed5a | |||
| 5809a470b9 | |||
| ed842d2195 | |||
| a0040ec9fb | |||
| 67b8aafd97 | |||
| ac417731d6 | |||
| 991c7e1965 | |||
| 0d9df29f58 | |||
| d54950698a | |||
| 394bd19b95 | |||
| b52d4000b4 | |||
| b32f962f11 | |||
| 0c1d3abd7e | |||
| d81564c308 | |||
| accb3b2405 | |||
| a63e3bef1e | |||
| 9d9aca53df | |||
| 5b6971fda5 | |||
| b496a401fc | |||
| 76c6818952 | |||
| 633a020212 | |||
| ab38238fe8 | |||
| 4145cacc52 | |||
| 6d59737d07 | |||
| 71ffcce06b | |||
| 72f43dbef2 | |||
| a5f4a68c6a | |||
| b2497ed877 | |||
| 9b42ea034a | |||
| f94bea3e74 | |||
| cde1e4311f | |||
| 847a40815f | |||
| 6fd03ef836 | |||
| c2ccc35e50 |
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Имя Docker volume для резервных копий БД
|
||||
BACKUP_VOLUME_NAME=game_pgbackups
|
||||
|
||||
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.3.0
|
||||
VERSION: 3.6.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.3.0</Version>
|
||||
<Version>3.6.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.6.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
|
||||
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
|
||||
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
@@ -125,6 +129,32 @@ docker compose up -d
|
||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
||||
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
||||
|
||||
## 📚 Портфолио завершённых приключений
|
||||
|
||||
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
|
||||
|
||||
### Возможности
|
||||
|
||||
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
|
||||
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
|
||||
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
|
||||
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
|
||||
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
|
||||
|
||||
### Хранение обложек
|
||||
|
||||
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
В `.env` можно переопределить имя volume:
|
||||
|
||||
```env
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
```
|
||||
|
||||
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
|
||||
|
||||
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
|
||||
|
||||
## 💾 Backup и восстановление
|
||||
|
||||
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
|
||||
|
||||
+11
-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.6.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,11 +67,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.6.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
||||
@@ -84,11 +86,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.6.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||
@@ -97,10 +101,12 @@ services:
|
||||
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
||||
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
||||
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
||||
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||
ports:
|
||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- web_keys:/app/dataprotection-keys
|
||||
- portfolio_covers:/app/portfolio-covers
|
||||
networks:
|
||||
- gmrelay
|
||||
healthcheck:
|
||||
@@ -116,6 +122,8 @@ volumes:
|
||||
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||
pgbackups:
|
||||
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
||||
portfolio_covers:
|
||||
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||
|
||||
networks:
|
||||
gmrelay:
|
||||
|
||||
@@ -8,19 +8,20 @@ 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, GM profiles, and completed-adventure portfolio pages 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/portfolio 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, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
|
||||
|
||||
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(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
|
||||
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -41,19 +42,21 @@ 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/portfolio pages, portfolio review submission and moderation, 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, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
System_Ext(discord, "Discord Gateway and REST API")
|
||||
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
|
||||
|
||||
Rel(gm, telegram, "Commands")
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule pages")
|
||||
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
|
||||
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
@@ -64,6 +67,7 @@ C4Container
|
||||
Rel(bot, db, "Npgsql + Dapper.AOT")
|
||||
Rel(discordBot, db, "Npgsql + Dapper")
|
||||
Rel(web, db, "Npgsql + Dapper")
|
||||
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
|
||||
```
|
||||
|
||||
## Level 3: Component - Session Interactions
|
||||
@@ -125,3 +129,57 @@ C4Component
|
||||
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
|
||||
Rel(healthCheck, discord, "HTTP /health")
|
||||
```
|
||||
|
||||
## Level 3: Component - Completed-Adventure Portfolios
|
||||
|
||||
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Completed-Adventure Portfolio Subsystem
|
||||
|
||||
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
|
||||
Person(player, "Player", "Submits one moderated review per completed adventure")
|
||||
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
|
||||
|
||||
Container_Boundary(web, "GmRelay.Web") {
|
||||
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
|
||||
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
|
||||
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
|
||||
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
|
||||
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
|
||||
}
|
||||
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
|
||||
|
||||
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
|
||||
Rel(player, pages, "Submits review")
|
||||
Rel(visitor, pages, "Reads public portfolio and approved reviews")
|
||||
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
|
||||
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
|
||||
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
|
||||
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
|
||||
Rel(authorized, covers, "Save, read, delete cover files")
|
||||
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
|
||||
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
|
||||
Rel(covers, coversVolume, "Filesystem reads/writes")
|
||||
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||
```
|
||||
|
||||
### Portfolio tables (PostgreSQL)
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
|
||||
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
|
||||
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
|
||||
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
|
||||
|
||||
### Cover storage boundary
|
||||
|
||||
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
|
||||
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
|
||||
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
|
||||
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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,424 @@
|
||||
# Completed Game Portfolio - Design Spec
|
||||
|
||||
> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games.
|
||||
|
||||
---
|
||||
|
||||
## Product Decisions
|
||||
|
||||
- A portfolio item is an independent adventure entity, not a flag on one session.
|
||||
- One adventure can reference multiple completed sessions from the same club.
|
||||
- Reviews are submitted by authenticated players, not entered manually by a GM.
|
||||
- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible.
|
||||
- Each player can submit one review per adventure.
|
||||
- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it.
|
||||
- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links.
|
||||
- Adventure visibility in a public GM profile does not depend on club-page visibility.
|
||||
- The public club page shows its portfolio block only when that club page is enabled.
|
||||
- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure.
|
||||
- Creation is available from the club page and through a quick action from a completed session.
|
||||
- Every published adventure has a dedicated public page at `/portfolio/{slug}`.
|
||||
- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters.
|
||||
|
||||
Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads.
|
||||
|
||||
Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Migration V029
|
||||
|
||||
Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`.
|
||||
|
||||
### `portfolio_games`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | primary key, generated | Adventure identifier |
|
||||
| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club |
|
||||
| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment |
|
||||
| `title` | `VARCHAR(255)` | not null | Adventure title |
|
||||
| `description` | `TEXT` | nullable for drafts | Public description |
|
||||
| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key |
|
||||
| `system` | `VARCHAR(50)` | nullable | Game system |
|
||||
| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format |
|
||||
| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date |
|
||||
| `is_public` | `BOOLEAN` | not null, default false | Public visibility |
|
||||
| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp |
|
||||
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
|
||||
Constraints and indexes:
|
||||
|
||||
```sql
|
||||
CHECK (NOT is_public OR (
|
||||
public_slug IS NOT NULL
|
||||
AND description IS NOT NULL
|
||||
AND cover_storage_key IS NOT NULL
|
||||
AND published_at IS NOT NULL
|
||||
))
|
||||
```
|
||||
|
||||
- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`.
|
||||
- Index on `(group_id, completed_at DESC)`.
|
||||
- Partial public index on `(completed_at DESC)` where `is_public = true`.
|
||||
|
||||
Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
|
||||
|
||||
Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
|
||||
|
||||
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
|
||||
|
||||
### `portfolio_game_sessions`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||
| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session |
|
||||
|
||||
Primary key: `(portfolio_game_id, session_id)`.
|
||||
|
||||
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
|
||||
|
||||
### `portfolio_game_masters`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||
| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM |
|
||||
|
||||
Primary key: `(portfolio_game_id, player_id)`.
|
||||
|
||||
Add an index on `(player_id, portfolio_game_id)` for public GM profile reads.
|
||||
|
||||
### `portfolio_game_reviews`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | primary key, generated | Review identifier |
|
||||
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||
| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference |
|
||||
| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot |
|
||||
| `body` | `TEXT` | not null | Review text |
|
||||
| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp |
|
||||
| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state |
|
||||
| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference |
|
||||
| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp |
|
||||
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||
|
||||
Constraints and indexes:
|
||||
|
||||
```sql
|
||||
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))
|
||||
UNIQUE (portfolio_game_id, author_player_id)
|
||||
```
|
||||
|
||||
- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`.
|
||||
- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`.
|
||||
- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`.
|
||||
- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`.
|
||||
|
||||
---
|
||||
|
||||
## Cover Storage
|
||||
|
||||
### Contract
|
||||
|
||||
Add a small storage abstraction:
|
||||
|
||||
```csharp
|
||||
public interface IPortfolioCoverStorage
|
||||
{
|
||||
Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteIfExistsAsync(
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
string GetPublicPath(string storageKey);
|
||||
}
|
||||
```
|
||||
|
||||
`PortfolioCoverUploadResult` carries the generated storage key and normalized content type.
|
||||
|
||||
### Local Implementation
|
||||
|
||||
- Store covers below a configured `PortfolioCovers:StoragePath`.
|
||||
- Mount that path from a dedicated Docker volume, `portfolio_covers`.
|
||||
- Serve files through a dedicated `/portfolio-covers/{storageKey}` route.
|
||||
- Generate random names. Never use the uploaded filename as the storage key.
|
||||
- Accept `image/jpeg`, `image/png`, and `image/webp`.
|
||||
- Limit uploads to 5 MiB.
|
||||
- Validate file signatures server-side before writing the final file.
|
||||
- Write to a temporary file, validate, then atomically move into place.
|
||||
- On successful replacement, delete the old file.
|
||||
- On database failure after upload, delete the newly uploaded file.
|
||||
- Deleting an adventure deletes its current cover after successful database deletion.
|
||||
|
||||
The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key.
|
||||
|
||||
---
|
||||
|
||||
## Service Contracts
|
||||
|
||||
Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.
|
||||
|
||||
Representative contracts:
|
||||
|
||||
```csharp
|
||||
public sealed record PublicPortfolioGame(
|
||||
string Slug,
|
||||
string Title,
|
||||
string Description,
|
||||
string CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt,
|
||||
string? ClubName,
|
||||
string? ClubSlug,
|
||||
IReadOnlyList<PublicPortfolioMaster> Masters,
|
||||
IReadOnlyList<PublicPortfolioReview> Reviews);
|
||||
|
||||
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
|
||||
|
||||
public sealed record PublicPortfolioReview(
|
||||
string AuthorDisplayName,
|
||||
string Body,
|
||||
DateTime CreatedAt);
|
||||
```
|
||||
|
||||
Protected DTOs may carry IDs needed for editing and moderation.
|
||||
|
||||
### Public Reads
|
||||
|
||||
- Load one public adventure by slug for `/portfolio/{slug}`.
|
||||
- Load public adventures for a public GM profile regardless of club-page visibility.
|
||||
- Load public adventures for a public club page only when the club page is enabled.
|
||||
- Return only reviews with explicit consent and `Approved` moderation state.
|
||||
|
||||
### Protected Management
|
||||
|
||||
Through `AuthorizedPortfolioService`:
|
||||
|
||||
- Load draft and published adventure cards for a managed club.
|
||||
- Load eligible completed sessions for a managed club.
|
||||
- Create a draft, optionally preselecting one completed session from the quick action.
|
||||
- Update title, slug, description, system, format, linked sessions, and displayed GMs.
|
||||
- Upload and replace the cover.
|
||||
- Publish or unpublish a card.
|
||||
- Load pending and historical reviews for moderation.
|
||||
- Approve, reject, or hide a review.
|
||||
|
||||
All management operations require the current user to be an owner or co-GM of the owning club.
|
||||
|
||||
### Review Submission
|
||||
|
||||
An authenticated user can submit a review from `/portfolio/{slug}` only when:
|
||||
|
||||
- The adventure is public.
|
||||
- The user explicitly checks publication consent.
|
||||
- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session.
|
||||
- The linked session is in the past.
|
||||
- The user has not submitted a review for this adventure before.
|
||||
|
||||
The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`.
|
||||
|
||||
---
|
||||
|
||||
## User Interface
|
||||
|
||||
### Protected Club Page
|
||||
|
||||
Extend `GroupDetails.razor` with a completed-adventures section:
|
||||
|
||||
- List draft and published portfolio cards.
|
||||
- Show title, publication state, linked-session count, displayed-GM count, and review moderation count.
|
||||
- Provide a create action, edit links, and a link to the club's completed-session list.
|
||||
|
||||
### Completed Session Quick Action
|
||||
|
||||
Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected.
|
||||
|
||||
### Adventure Editor
|
||||
|
||||
Add a protected editor page:
|
||||
|
||||
- Title and public slug.
|
||||
- Description.
|
||||
- System and format.
|
||||
- Multi-select of completed sessions from the same club.
|
||||
- Multi-select of displayed GMs.
|
||||
- Cover upload and replacement.
|
||||
- Draft save and publish/unpublish actions.
|
||||
- Review moderation list with approve, reject, and hide actions.
|
||||
|
||||
The editor surfaces validation errors without publishing partial data.
|
||||
|
||||
### Public GM Profile
|
||||
|
||||
Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility.
|
||||
|
||||
### Public Club Page
|
||||
|
||||
Extend `/club/{slug}` with the same compact cards when the public club page is enabled.
|
||||
|
||||
### Public Adventure Page
|
||||
|
||||
Add `/portfolio/{slug}`:
|
||||
|
||||
- Cover hero.
|
||||
- Title, description, completion date, system, and format.
|
||||
- Optional public club link.
|
||||
- Public links to selected GM profiles.
|
||||
- Approved reviews with display-name snapshots.
|
||||
- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox.
|
||||
- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message.
|
||||
- For an anonymous visitor: a sign-in prompt instead of the form.
|
||||
|
||||
---
|
||||
|
||||
## Privacy And Security
|
||||
|
||||
- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths.
|
||||
- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename.
|
||||
- Random storage keys prevent filename guessing and path traversal.
|
||||
- Review text is rendered as encoded text through normal Razor rendering.
|
||||
- Authorization is checked in the service layer for every management operation.
|
||||
- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization.
|
||||
- The `/showcase` query keeps its current future-session condition and does not include completed adventures.
|
||||
|
||||
---
|
||||
|
||||
## Docker And Configuration
|
||||
|
||||
Add:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
discord:
|
||||
depends_on:
|
||||
bot:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
depends_on:
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||
volumes:
|
||||
- portfolio_covers:/app/portfolio-covers
|
||||
|
||||
volumes:
|
||||
portfolio_covers:
|
||||
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||
```
|
||||
|
||||
Development configuration uses a local directory under the application content root or an explicitly configured path.
|
||||
|
||||
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
|
||||
|
||||
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Update:
|
||||
|
||||
- `README.md` with public portfolio capability and local cover-storage configuration.
|
||||
- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Follow TDD for production changes.
|
||||
|
||||
### Schema And Contracts
|
||||
|
||||
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate.
|
||||
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion.
|
||||
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
|
||||
- Existing showcase tests continue to assert the future-session catalog boundary.
|
||||
|
||||
### Authorization And Eligibility
|
||||
|
||||
- Owner and co-GM can manage a club adventure.
|
||||
- A manager of another club cannot manage it.
|
||||
- Only registered players from linked past sessions can submit.
|
||||
- A registered player can submit only once.
|
||||
- Consent is required.
|
||||
- A new review is pending and not public.
|
||||
- Only approved reviews are returned publicly.
|
||||
|
||||
### Cover Storage
|
||||
|
||||
- Accept valid JPEG, PNG, and WebP signatures.
|
||||
- Reject unsupported types, mismatched signatures, oversized files, and unsafe names.
|
||||
- Replacement deletes the old file only after the new file is stored.
|
||||
- Cleanup removes a newly uploaded file when persistence fails.
|
||||
|
||||
### UI Source Contracts
|
||||
|
||||
- Protected club and session-history pages expose management entry points.
|
||||
- Public GM and club pages render compact portfolio sections.
|
||||
- The public adventure page renders approved reviews and the conditional review form.
|
||||
- CSS defines responsive portfolio cards, cover hero, editor layout, and review states.
|
||||
|
||||
### Regression
|
||||
|
||||
- Run the full test suite.
|
||||
- Run `dotnet build`.
|
||||
- Run `dotnet format --verify-no-changes`.
|
||||
- Visually inspect the protected editor and public portfolio pages in the browser.
|
||||
|
||||
---
|
||||
|
||||
## Version Bump
|
||||
|
||||
Issue label: `type:feature` -> minor bump.
|
||||
|
||||
Current: `3.5.1` -> Next: `3.6.0`.
|
||||
|
||||
Synchronize:
|
||||
|
||||
- `Directory.Build.props`
|
||||
- `compose.yaml` (`bot`, `discord`, and `web` image tags)
|
||||
- `.gitea/workflows/deploy.yml` (`VERSION`)
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Mapping
|
||||
|
||||
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
|
||||
- [ ] A portfolio adventure can group one or more completed sessions from the same club.
|
||||
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
|
||||
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
|
||||
- [ ] A public club page shows portfolio cards when enabled.
|
||||
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
|
||||
- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent.
|
||||
- [ ] Reviews remain non-public until owner/co-GM moderation approves them.
|
||||
- [ ] Public DTOs and HTML do not expose private identifiers.
|
||||
- [ ] Uploaded covers survive container replacement through a persistent Docker volume.
|
||||
- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation.
|
||||
- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games.
|
||||
@@ -2,18 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithPgAdmin()
|
||||
.AddDatabase("gmrelay-db");
|
||||
.AddDatabase("gmrelaydb");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
|
||||
.WithHttpHealthCheck("/health", endpointName: "health");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Web>("web")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
@@ -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,261 @@
|
||||
-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews.
|
||||
|
||||
CREATE TABLE portfolio_games (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
public_slug VARCHAR(160),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cover_storage_key TEXT,
|
||||
system VARCHAR(50),
|
||||
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
NOT is_public
|
||||
OR (
|
||||
public_slug IS NOT NULL
|
||||
AND description IS NOT NULL
|
||||
AND cover_storage_key IS NOT NULL
|
||||
AND published_at IS NOT NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
|
||||
ON portfolio_games (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_games_group
|
||||
ON portfolio_games (group_id, completed_at DESC);
|
||||
|
||||
CREATE INDEX ix_portfolio_games_public
|
||||
ON portfolio_games (completed_at DESC)
|
||||
WHERE is_public = true;
|
||||
|
||||
CREATE TABLE portfolio_game_sessions (
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portfolio_game_id, session_id),
|
||||
UNIQUE (session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE portfolio_game_masters (
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portfolio_game_id, player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_masters_player
|
||||
ON portfolio_game_masters (player_id, portfolio_game_id);
|
||||
|
||||
CREATE FUNCTION lock_portfolio_publication_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
|
||||
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
|
||||
BEFORE DELETE ON game_groups
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
|
||||
BEFORE DELETE ON players
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE FUNCTION validate_public_portfolio_game_required_links()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
target_portfolio_game_id UUID;
|
||||
target_portfolio_game_ids UUID[];
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
|
||||
IF TG_TABLE_NAME = 'portfolio_games' THEN
|
||||
target_portfolio_game_ids := ARRAY[NEW.id];
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
|
||||
ELSE
|
||||
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
|
||||
END IF;
|
||||
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION
|
||||
'portfolio publication validation requires read committed isolation'
|
||||
USING ERRCODE = '0A000';
|
||||
END IF;
|
||||
|
||||
SELECT pg.id
|
||||
INTO target_portfolio_game_id
|
||||
FROM portfolio_games pg
|
||||
WHERE pg.id = ANY(target_portfolio_game_ids)
|
||||
AND pg.is_public = true
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_masters pgm
|
||||
WHERE pgm.portfolio_game_id = pg.id
|
||||
)
|
||||
)
|
||||
LIMIT 1;
|
||||
|
||||
IF target_portfolio_game_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'published portfolio game % must have at least one linked session and at least one linked master',
|
||||
target_portfolio_game_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
final_scheduled_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
SELECT s.scheduled_at
|
||||
INTO final_scheduled_at
|
||||
FROM sessions s
|
||||
WHERE s.id = NEW.id;
|
||||
|
||||
IF final_scheduled_at >= now() THEN
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION
|
||||
'portfolio future reschedule requires read committed isolation'
|
||||
USING ERRCODE = '0A000';
|
||||
END IF;
|
||||
|
||||
PERFORM pg.id
|
||||
FROM portfolio_games pg
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
)
|
||||
ORDER BY pg.id
|
||||
FOR UPDATE OF pg;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
WHERE pg.is_public = true
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
|
||||
AFTER UPDATE OF scheduled_at ON sessions
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
|
||||
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
|
||||
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
|
||||
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE TABLE portfolio_game_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
author_display_name VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
publication_consent_at TIMESTAMPTZ NOT NULL,
|
||||
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
|
||||
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||
moderated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (portfolio_game_id, author_player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_author
|
||||
ON portfolio_game_reviews (author_player_id);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_moderator
|
||||
ON portfolio_game_reviews (moderated_by_player_id)
|
||||
WHERE moderated_by_player_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_public
|
||||
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_pending
|
||||
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||
WHERE moderation_status = 'Pending';
|
||||
@@ -32,7 +32,9 @@ public sealed class DiscordDeleteSessionHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
@@ -43,6 +45,39 @@ public sealed class DiscordDeleteSessionHandler(
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||
transaction: transaction);
|
||||
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
AND pg.is_public = true
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
var deletedRows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM sessions s
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,12 @@ public sealed class DeleteSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Fetch session and verify group manager.
|
||||
// 1. Use the database mutation order before locking the session or linked portfolio cards.
|
||||
await connection.ExecuteAsync(
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||
transaction: transaction);
|
||||
|
||||
// 2. Lock the session before any linked portfolio card and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
@@ -49,6 +54,7 @@ public sealed class DeleteSessionHandler(
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||
|
||||
@@ -62,7 +68,21 @@ public sealed class DeleteSessionHandler(
|
||||
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
// 2. Delete session
|
||||
// 3. Unpublish a linked portfolio card before its required session link cascades away.
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
FROM portfolio_game_sessions pgs
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND pgs.session_id = @SessionId
|
||||
AND pg.is_public = true
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
// 4. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
|
||||
@@ -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.6.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
@page "/group/{GroupId:guid}/completed"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Проведённые сессии — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/group/@GroupId">Группа</a></li>
|
||||
<li class="active">Проведённые сессии</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📚 Проведённые сессии</h2>
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">
|
||||
Добавьте проведённые игры в портфолио — система создаст черновик и предложит заполнить детали.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessions is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 55%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card animate-slide-up">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-title">Проведённых сессий пока нет</div>
|
||||
<p class="empty-state-text">Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-completed-list animate-slide-up">
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
<div class="portfolio-completed-row">
|
||||
<div class="portfolio-completed-info">
|
||||
<a href="/session/@session.Id/history" class="portfolio-completed-title">@session.Title</a>
|
||||
<span class="portfolio-completed-date">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</div>
|
||||
<div class="portfolio-completed-actions">
|
||||
<button type="button" class="btn-gm btn-gm-primary" disabled="@(creatingDraftSessionId == session.Id)" @onclick="() => AddToPortfolio(session.Id)">
|
||||
@(creatingDraftSessionId == session.Id ? "⏳..." : "➕ Добавить в портфолио")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
|
||||
private IReadOnlyList<PortfolioSessionOption>? sessions;
|
||||
private Guid? creatingDraftSessionId;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
sessions = await PortfolioService.GetCompletedSessionsForCurrentUserAsync(GroupId);
|
||||
}
|
||||
|
||||
private async Task AddToPortfolio(Guid sessionId)
|
||||
{
|
||||
errorMessage = null;
|
||||
creatingDraftSessionId = sessionId;
|
||||
|
||||
try
|
||||
{
|
||||
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, sessionId);
|
||||
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось создать черновик: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creatingDraftSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
@page "/group/{GroupId:guid}"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -138,6 +140,60 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (portfolioGames is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Проведённые приключения</h3>
|
||||
<p>Черновики и опубликованные приключения для каталога мастера.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@isCreatingDraft" @onclick="CreateDraft">
|
||||
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Создать")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (portfolioGames.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Приключений пока нет</div>
|
||||
<p class="empty-state-text">Создайте первый черновик и добавьте проведённые сессии.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-management-list">
|
||||
@foreach (var game in portfolioGames)
|
||||
{
|
||||
<div class="portfolio-management-row">
|
||||
<div class="portfolio-management-info">
|
||||
<a href="/portfolio/manage/@game.Id" class="portfolio-management-title">@game.Title</a>
|
||||
<span class="status-badge @(game.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(game.IsPublic ? "Опубликовано" : "Черновик")
|
||||
</span>
|
||||
</div>
|
||||
<div class="portfolio-management-meta">
|
||||
<span class="status-badge status-info">@game.SessionCount игр</span>
|
||||
<span class="status-badge status-info">@game.MasterCount мастеров</span>
|
||||
@if (game.PendingReviewCount > 0)
|
||||
{
|
||||
<span class="status-badge status-warning">@game.PendingReviewCount на модерации</span>
|
||||
}
|
||||
</div>
|
||||
<div class="portfolio-management-actions">
|
||||
<a href="/portfolio/manage/@game.Id" class="btn-gm btn-gm-outline">✏️ Изменить</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<a href="/group/@GroupId/completed" class="btn-gm btn-gm-outline">📜 Все проведённые сессии</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (campaignTemplates is not null)
|
||||
{
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
@@ -481,6 +537,7 @@
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private WebPublicGroupSettings? publicSettings;
|
||||
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private Guid? promotingSessionId;
|
||||
@@ -490,6 +547,7 @@
|
||||
private Guid? publishingSessionId;
|
||||
private string? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private bool isCreatingDraft;
|
||||
private bool savingPublicSettings;
|
||||
private string? currentPlatform;
|
||||
private string? externalUserId;
|
||||
@@ -545,11 +603,38 @@
|
||||
return;
|
||||
}
|
||||
|
||||
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
||||
|
||||
RebuildBatchModels();
|
||||
RebuildCampaignTemplateModels();
|
||||
RebuildPublicSettingsModel();
|
||||
}
|
||||
|
||||
private async Task CreateDraft()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isCreatingDraft = true;
|
||||
|
||||
try
|
||||
{
|
||||
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, null);
|
||||
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось создать черновик: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCreatingDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SavePublicSettings()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
@page "/portfolio/manage/{PortfolioGameId:guid}"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio.Covers
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Портфолио — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/group/@groupId">Группа</a></li>
|
||||
<li class="active">Портфолио</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📚 Управление портфолио</h2>
|
||||
@if (editor is not null)
|
||||
{
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@editor.Title</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||
✅ @successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editor is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Параметры публикации</h3>
|
||||
<p>Управление видимостью и обложкой приключения.</p>
|
||||
</div>
|
||||
<span class="status-badge @(editor.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(editor.IsPublic ? "Опубликовано" : "Черновик")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-grid">
|
||||
<div class="portfolio-editor-cover">
|
||||
@if (!string.IsNullOrEmpty(editor.CoverPath))
|
||||
{
|
||||
<img src="@editor.CoverPath" alt="Обложка" class="portfolio-editor-cover-image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-editor-cover-empty">Обложка не загружена</div>
|
||||
}
|
||||
<InputFile OnChange="HandleFileSelected" accept="image/jpeg,image/png,image/webp" class="portfolio-editor-cover-input" />
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@isUploadingCover" @onclick="TriggerCoverUpload">
|
||||
@(isUploadingCover ? "⏳ Загружаем..." : "🖼 Заменить обложку")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-fields">
|
||||
<EditForm Model="@editorModel" OnValidSubmit="SaveDraft">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название</label>
|
||||
<InputText @bind-Value="editorModel.Title" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес (slug)</label>
|
||||
<InputText @bind-Value="editorModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-run".</div>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Система</label>
|
||||
<InputText @bind-Value="editorModel.System" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Формат</label>
|
||||
<InputText @bind-Value="editorModel.Format" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Описание</label>
|
||||
<InputTextArea @bind-Value="editorModel.Description" class="gm-form-control" />
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSaving">
|
||||
@(isSaving ? "⏳ Сохраняем..." : "💾 Сохранить")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<div class="portfolio-editor-publish-row">
|
||||
<button type="button" class="btn-gm @(editor.IsPublic ? "btn-gm-outline" : "btn-gm-success")" disabled="@isUpdatingPublication" @onclick="() => SetPublication(!editor.IsPublic)">
|
||||
@(isUpdatingPublication
|
||||
? "Обновляем..."
|
||||
: editor.IsPublic ? "Скрыть из каталога" : "Опубликовать")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@isDeleting" @onclick="DeletePortfolio">
|
||||
@(isDeleting ? "⏳ Удаляем..." : "🗑 Удалить")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Проведённые сессии</h3>
|
||||
<p>Отметьте игры, которые вошли в это приключение.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@editorModel.SessionIds.Count</span>
|
||||
</div>
|
||||
<div class="portfolio-option-list">
|
||||
@foreach (var session in editor.Sessions)
|
||||
{
|
||||
<label class="portfolio-option-row">
|
||||
<input type="checkbox" checked="@session.Selected" @onchange="e => ToggleSession(session.Id, (bool)(e.Value ?? false))" />
|
||||
<span class="portfolio-option-title">@session.Title</span>
|
||||
<span class="portfolio-option-meta">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Мастера приключения</h3>
|
||||
<p>Выберите мастеров, которые вели это приключение.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@editorModel.MasterPlayerIds.Count</span>
|
||||
</div>
|
||||
<div class="portfolio-option-list">
|
||||
@foreach (var master in editor.Masters)
|
||||
{
|
||||
<label class="portfolio-option-row">
|
||||
<input type="checkbox" checked="@master.Selected" @onchange="e => ToggleMaster(master.PlayerId, (bool)(e.Value ?? false))" />
|
||||
<span class="portfolio-option-title">@master.DisplayName</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Модерация отзывов</h3>
|
||||
<p>Одобрите, отклоните или скройте отзывы игроков перед публикацией.</p>
|
||||
</div>
|
||||
<span class="status-badge @(editor.Reviews.Any(r => r.ModerationStatus == "Pending") ? "status-warning" : "status-neutral")">
|
||||
@editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (editor.Reviews.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Отзывов пока нет</div>
|
||||
<p class="empty-state-text">Игроки смогут оставить отзыв после публикации приключения.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-review-moderation">
|
||||
@foreach (var review in editor.Reviews)
|
||||
{
|
||||
<div class="portfolio-review-row">
|
||||
<div class="portfolio-review-meta">
|
||||
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||
<span class="status-badge @GetReviewStatusClass(review.ModerationStatus)">@TranslateReviewStatus(review.ModerationStatus)</span>
|
||||
<span class="portfolio-review-date">@review.CreatedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||
</div>
|
||||
<p class="portfolio-review-body">@review.Body</p>
|
||||
<div class="portfolio-review-actions">
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Approved"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Одобрить")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Rejected"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Отклонить")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Hidden"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Скрыть")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid PortfolioGameId { get; set; }
|
||||
|
||||
private PortfolioGameEditor? editor;
|
||||
private PortfolioEditorModel editorModel = new();
|
||||
private Guid? groupId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private bool isSaving;
|
||||
private bool isUploadingCover;
|
||||
private bool isUpdatingPublication;
|
||||
private bool isDeleting;
|
||||
private Guid? moderatingReviewId;
|
||||
private IBrowserFile? pendingCoverFile;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
editor = await PortfolioService.GetPortfolioGameForCurrentUserAsync(PortfolioGameId);
|
||||
if (editor is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = editor.GroupId;
|
||||
editorModel = new PortfolioEditorModel
|
||||
{
|
||||
Title = editor.Title,
|
||||
PublicSlug = editor.PublicSlug ?? string.Empty,
|
||||
Description = editor.Description ?? string.Empty,
|
||||
System = editor.System ?? string.Empty,
|
||||
Format = editor.Format ?? string.Empty,
|
||||
SessionIds = editor.Sessions.Where(s => s.Selected).Select(s => s.Id).ToList(),
|
||||
MasterPlayerIds = editor.Masters.Where(m => m.Selected).Select(m => m.PlayerId).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private void ToggleSession(Guid sessionId, bool isChecked)
|
||||
{
|
||||
if (isChecked)
|
||||
{
|
||||
if (!editorModel.SessionIds.Contains(sessionId))
|
||||
{
|
||||
editorModel.SessionIds.Add(sessionId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
editorModel.SessionIds.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleMaster(Guid playerId, bool isChecked)
|
||||
{
|
||||
if (isChecked)
|
||||
{
|
||||
if (!editorModel.MasterPlayerIds.Contains(playerId))
|
||||
{
|
||||
editorModel.MasterPlayerIds.Add(playerId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
editorModel.MasterPlayerIds.Remove(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveDraft()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isSaving = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.UpdateDraftForCurrentUserAsync(
|
||||
PortfolioGameId,
|
||||
new PortfolioGameUpdate(
|
||||
editorModel.Title,
|
||||
editorModel.PublicSlug,
|
||||
editorModel.Description,
|
||||
editorModel.System,
|
||||
editorModel.Format,
|
||||
editorModel.SessionIds,
|
||||
editorModel.MasterPlayerIds));
|
||||
successMessage = "Черновик сохранён.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось сохранить: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerCoverUpload()
|
||||
{
|
||||
// The InputFile control is rendered with a label. No-op click handler kept for symmetry.
|
||||
}
|
||||
|
||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
var file = e.File;
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCoverFile = file;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isUploadingCover = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream(LocalPortfolioCoverStorage.MaxBytes);
|
||||
await PortfolioService.ReplaceCoverForCurrentUserAsync(PortfolioGameId, stream, file.ContentType);
|
||||
successMessage = "Обложка обновлена.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось загрузить обложку: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUploadingCover = false;
|
||||
pendingCoverFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetPublication(bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isUpdatingPublication = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.SetPublicationForCurrentUserAsync(PortfolioGameId, isPublic);
|
||||
successMessage = isPublic ? "Приключение опубликовано." : "Приключение скрыто.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публикацию: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUpdatingPublication = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeletePortfolio()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isDeleting = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.DeleteForCurrentUserAsync(PortfolioGameId);
|
||||
Navigation.NavigateTo(groupId.HasValue ? $"/group/{groupId.Value}" : "/");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось удалить: " + ex.Message;
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Moderate(Guid reviewId, string moderationStatus)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
moderatingReviewId = reviewId;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.ModerateReviewForCurrentUserAsync(PortfolioGameId, reviewId, moderationStatus);
|
||||
successMessage = "Модерация обновлена.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить отзыв: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
moderatingReviewId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetReviewStatusClass(string status) => status switch
|
||||
{
|
||||
"Approved" => "status-success",
|
||||
"Rejected" => "status-danger",
|
||||
"Hidden" => "status-warning",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateReviewStatus(string status) => status switch
|
||||
{
|
||||
"Approved" => "Одобрен",
|
||||
"Rejected" => "Отклонён",
|
||||
"Hidden" => "Скрыт",
|
||||
_ => "На модерации"
|
||||
};
|
||||
|
||||
private sealed class PortfolioEditorModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string PublicSlug { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string System { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public List<Guid> SessionIds { get; set; } = new();
|
||||
public List<Guid> MasterPlayerIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,128 @@
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@attribute [Authorize]
|
||||
@inject ISessionStore SessionStore
|
||||
@inject AuthorizedSessionService AuthorizedSessionService
|
||||
@inject IConfiguration Configuration
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Профиль — GM-Relay</PageTitle>
|
||||
|
||||
<div class="profile-container">
|
||||
<h1 class="page-title">Профиль</h1>
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Профиль</li>
|
||||
</ul>
|
||||
|
||||
@if (identities is null)
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>Профиль</h2>
|
||||
<p>Управление публичным профилем мастера и связанными аккаунтами.</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
<p class="loading-text">Загрузка...</p>
|
||||
<div class="gm-alert gm-alert-danger animate-fade-in" style="margin-bottom: 1rem;">
|
||||
@errorMessage
|
||||
</div>
|
||||
}
|
||||
else if (identities.Count == 0)
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(successMessage))
|
||||
{
|
||||
<div class="profile-card">
|
||||
<p>Связанные аккаунты не найдены.</p>
|
||||
<div class="gm-alert gm-alert-success animate-fade-in" style="margin-bottom: 1rem;">
|
||||
@successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (masterProfile is null)
|
||||
{
|
||||
<div class="glass-card animate-fade-in" style="padding: 2rem; margin-bottom: 1rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 40%;"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="profile-card">
|
||||
<h2 class="section-title">Связанные аккаунты</h2>
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="profile-card-header">
|
||||
<div>
|
||||
<h3>Публичный профиль мастера</h3>
|
||||
<p>Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||
</div>
|
||||
<span class="status-badge @(masterProfile.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(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>
|
||||
}
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Связанные аккаунты</h3>
|
||||
<p>Аккаунты Telegram и Discord, привязанные к вашему профилю.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%;"></div>
|
||||
}
|
||||
else if (identities.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Аккаунты не найдены</div>
|
||||
<p class="empty-state-text">Привяжите Telegram или Discord, чтобы управлять профилем.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="identity-list">
|
||||
@foreach (var id in identities)
|
||||
{
|
||||
@@ -36,7 +136,7 @@
|
||||
</div>
|
||||
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
|
||||
{
|
||||
<button class="btn btn-secondary btn-small"
|
||||
<button class="btn-gm btn-gm-danger"
|
||||
@onclick="() => Unlink(id.Platform, id.ExternalUserId)"
|
||||
disabled="@isUnlinking">
|
||||
Отвязать
|
||||
@@ -44,25 +144,34 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="identity-badge">Текущий</span>
|
||||
<span class="status-badge status-success">Текущий</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Добавить аккаунт</h3>
|
||||
<p>Привяжите дополнительные платформы для входа.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<h2 class="section-title">Добавить аккаунт</h2>
|
||||
@if (!HasLinkedPlatform("Discord"))
|
||||
{
|
||||
<a href="/auth/discord" class="btn btn-primary">
|
||||
<a href="/auth/discord" class="login-btn-discord" style="margin-bottom: 0.75rem;">
|
||||
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
Привязать Discord
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted-text">Discord уже привязан.</p>
|
||||
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">Discord уже привязан.</p>
|
||||
}
|
||||
|
||||
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
|
||||
@@ -78,25 +187,18 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
<div class="alert alert-error">@errorMessage</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(successMessage))
|
||||
{
|
||||
<div class="alert alert-success">@successMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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 +233,7 @@
|
||||
}
|
||||
|
||||
await LoadIdentities();
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
|
||||
private async Task LoadIdentities()
|
||||
@@ -152,6 +255,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 +345,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
@page "/club/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@@ -38,6 +41,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)
|
||||
@@ -66,12 +78,22 @@ else if (club is not null)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
{
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Завершённые игры клуба</h2>
|
||||
<p>Публичные портфолио, опубликованные мастерами этого клуба.</p>
|
||||
<PortfolioCardGrid Games="portfolioGames" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private WebPublicClub? club;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
@@ -84,14 +106,20 @@ else if (club is not null)
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
club = string.IsNullOrWhiteSpace(Slug)
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
club = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
@page "/gm/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
{
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Портфолио</h2>
|
||||
<p>Завершённые игры мастера, открытые для публичного просмотра.</p>
|
||||
<PortfolioCardGrid Games="portfolioGames" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private GmRelay.Web.Services.PublicMasterProfile? profile;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
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;
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
profile = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
SessionStatus.Planned => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
@page "/portfolio/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject AuthorizedPortfolioService AuthorizedPortfolio
|
||||
@inject NavigationManager Navigation
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && game 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 (game is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Портфолио {game.Title} — завершённая игра в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(game.CoverPath))
|
||||
{
|
||||
<div class="portfolio-cover-hero" style="background-image: url('@game.CoverPath')"></div>
|
||||
}
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-success">Завершено</span>
|
||||
<h1>@game.Title</h1>
|
||||
<p>Завершено @game.CompletedAt.ToLocalTime().FormatMoscow()</p>
|
||||
<div class="session-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(game.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(game.System)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(game.Format)</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="glass-card public-session-detail">
|
||||
@if (!string.IsNullOrWhiteSpace(game.Description))
|
||||
{
|
||||
<div class="session-description">
|
||||
<h3>Описание</h3>
|
||||
<p>@game.Description</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.Masters.Count > 0)
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Мастера</span>
|
||||
@foreach (var master in game.Masters)
|
||||
{
|
||||
<a href="@($"/gm/{master.Slug}")">@master.DisplayName</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(game.ClubSlug) && !string.IsNullOrWhiteSpace(game.ClubName))
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Клуб</span>
|
||||
<a href="@($"/club/{game.ClubSlug}")">@game.ClubName</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicPortfolioUrl" target="_blank" rel="noopener noreferrer">Ссылка на портфолио</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Отзывы игроков</h2>
|
||||
@if (game.Reviews.Count == 0)
|
||||
{
|
||||
<p>Пока нет одобренных отзывов.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="portfolio-review-list">
|
||||
@foreach (var review in game.Reviews)
|
||||
{
|
||||
<li class="portfolio-review-card">
|
||||
<div class="portfolio-review-meta">
|
||||
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||
<span class="portfolio-review-date">@review.CreatedAt.ToLocalTime().FormatMoscowShort()</span>
|
||||
</div>
|
||||
<p class="portfolio-review-body">@review.Body</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Оставить отзыв</h2>
|
||||
@switch (submissionState)
|
||||
{
|
||||
case PortfolioReviewSubmissionState.RequiresAuthentication:
|
||||
<p>Войдите, чтобы оставить отзыв об этом приключении.</p>
|
||||
<div class="public-settings-actions">
|
||||
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти</a>
|
||||
</div>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.Ineligible:
|
||||
<p>Отзыв могут оставить только игроки, участвовавшие в этом приключении.</p>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.AlreadySubmitted:
|
||||
<p>Отзыв отправлен на модерацию.</p>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.Eligible:
|
||||
<EditForm Model="reviewModel" OnValidSubmit="SubmitReviewAsync" FormName="portfolio-review">
|
||||
<div class="portfolio-editor-fields">
|
||||
<label>
|
||||
<span>Текст отзыва</span>
|
||||
<textarea class="portfolio-review-textarea"
|
||||
@bind="reviewModel.Body"
|
||||
@bind:event="oninput"
|
||||
maxlength="2000"
|
||||
minlength="10"
|
||||
rows="5"
|
||||
placeholder="Что вам запомнилось в этой игре?"
|
||||
required></textarea>
|
||||
</label>
|
||||
<label class="portfolio-review-consent">
|
||||
<input type="checkbox"
|
||||
name="publicationConsent"
|
||||
@bind="reviewModel.PublicationConsent"
|
||||
required />
|
||||
<span>Я даю согласие на публикацию этого отзыва</span>
|
||||
</label>
|
||||
@if (!string.IsNullOrWhiteSpace(submissionError))
|
||||
{
|
||||
<p class="portfolio-review-error">@submissionError</p>
|
||||
}
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "Отправка..." : "Отправить отзыв")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
break;
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private PublicPortfolioGame? game;
|
||||
private PortfolioReviewSubmissionState submissionState = PortfolioReviewSubmissionState.RequiresAuthentication;
|
||||
private ReviewFormModel reviewModel = new();
|
||||
private string? submissionError;
|
||||
private bool isSubmitting;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => game is null ? "Портфолио — GM-Relay" : $"{game.Title} — GM-Relay";
|
||||
|
||||
private string PublicPortfolioUrl => Navigation.ToAbsoluteUri($"/portfolio/{Slug}").ToString();
|
||||
|
||||
private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/portfolio/{Slug}")}";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
game = trimmedSlug is null
|
||||
? null
|
||||
: await PortfolioStore.GetPublicPortfolioGameBySlugAsync(trimmedSlug);
|
||||
|
||||
if (game is not null)
|
||||
{
|
||||
submissionState = await AuthorizedPortfolio.GetReviewSubmissionStateForCurrentUserAsync(game.Slug);
|
||||
}
|
||||
|
||||
reviewModel = new ReviewFormModel();
|
||||
submissionError = null;
|
||||
isSubmitting = false;
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitReviewAsync()
|
||||
{
|
||||
if (game is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reviewModel.PublicationConsent)
|
||||
{
|
||||
submissionError = "Нужно подтвердить согласие на публикацию.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reviewModel.Body) || reviewModel.Body.Trim().Length < 10)
|
||||
{
|
||||
submissionError = "Отзыв должен содержать не меньше 10 символов.";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
submissionError = null;
|
||||
try
|
||||
{
|
||||
await AuthorizedPortfolio.SubmitReviewForCurrentUserAsync(
|
||||
game.Slug,
|
||||
reviewModel.Body,
|
||||
reviewModel.PublicationConsent);
|
||||
submissionState = PortfolioReviewSubmissionState.AlreadySubmitted;
|
||||
reviewModel = new ReviewFormModel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
submissionError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 sealed class ReviewFormModel
|
||||
{
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public bool PublicationConsent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
@page "/session/{SessionId:guid}/history"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -22,6 +24,14 @@
|
||||
{
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
|
||||
}
|
||||
@if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow)
|
||||
{
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<button type="button" class="btn-gm btn-gm-primary" disabled="@isCreatingDraft" @onclick="AddToPortfolio">
|
||||
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Добавить в портфолио")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entries is null)
|
||||
@@ -78,6 +88,8 @@
|
||||
private List<SessionAuditLogEntry>? entries;
|
||||
private string? sessionTitle;
|
||||
private Guid? groupId;
|
||||
private WebSession? session;
|
||||
private bool isCreatingDraft;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -88,7 +100,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||
if (session is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
@@ -100,6 +112,30 @@
|
||||
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
|
||||
}
|
||||
|
||||
private async Task AddToPortfolio()
|
||||
{
|
||||
if (groupId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isCreatingDraft = true;
|
||||
|
||||
try
|
||||
{
|
||||
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId);
|
||||
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch
|
||||
{
|
||||
isCreatingDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetChangeTypeLabel(string changeType) => changeType switch
|
||||
{
|
||||
"Title" => "Название",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<div class="portfolio-grid">
|
||||
@foreach (var game in Games)
|
||||
{
|
||||
<a class="portfolio-card" href="@($"/portfolio/{game.Slug}")">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CoverPath))
|
||||
{
|
||||
<div class="portfolio-card-cover" style="background-image: url('@game.CoverPath')"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-card-cover portfolio-card-cover-empty">
|
||||
<span>Без обложки</span>
|
||||
</div>
|
||||
}
|
||||
<div class="portfolio-card-body">
|
||||
<h3>@game.Title</h3>
|
||||
<div class="portfolio-card-meta">
|
||||
<span class="status-badge status-success">Завершено</span>
|
||||
<span class="portfolio-card-date">@game.CompletedAt.ToLocalTime().FormatMoscowShort()</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(game.System) || !string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<div class="portfolio-card-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(game.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(game.System)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(game.Format)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<PublicPortfolioCard> Games { get; set; } = [];
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/dataprotection-keys && chown -R $APP_UID:$APP_UID /app/dataprotection-keys
|
||||
RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \
|
||||
&& chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
USER $APP_UID
|
||||
|
||||
@@ -2,6 +2,8 @@ using GmRelay.Web;
|
||||
using GmRelay.Web.Components;
|
||||
using GmRelay.Web.Health;
|
||||
using GmRelay.Web.Services;
|
||||
using GmRelay.Web.Services.Portfolio;
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@@ -37,12 +39,15 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
||||
|
||||
// Add Services
|
||||
builder.Services.AddSingleton<TelegramAuthService>();
|
||||
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
|
||||
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
||||
builder.Services.AddSingleton<DiscordAuthService>();
|
||||
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
||||
builder.Services.AddScoped<AuthorizedPortfolioService>();
|
||||
|
||||
// Add Bot Client
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
@@ -94,6 +99,8 @@ app.Use(async (context, next) =>
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UsePortfolioCoverFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
@@ -161,6 +168,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 +176,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 +190,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 +198,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(
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Security.Claims;
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public sealed class AuthorizedPortfolioService(
|
||||
IPortfolioStore portfolioStore,
|
||||
ISessionStore sessionStore,
|
||||
IPortfolioCoverStorage coverStorage,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private (string Platform, string ExternalUserId, string? Name)? GetCurrentIdentity()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||
return null;
|
||||
|
||||
var name = user.FindFirst(ClaimTypes.Name)?.Value;
|
||||
return (platform, externalUserId, name);
|
||||
}
|
||||
|
||||
private async Task<(string Platform, string ExternalUserId)> RequireManagerAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, "<anonymous>");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
private async Task<(Guid GroupId, string Platform, string ExternalUserId)> RequireManagerForGameAsync(Guid portfolioGameId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(portfolioGameId, "<anonymous>");
|
||||
}
|
||||
|
||||
var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId);
|
||||
if (groupId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Portfolio game not found.");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(portfolioGameId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
// --- Protected reads ---
|
||||
|
||||
public async Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await portfolioStore.GetPortfolioGamesForGroupAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<PortfolioGameEditor?> GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId);
|
||||
if (groupId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await portfolioStore.GetPortfolioGameForManagementAsync(portfolioGameId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortfolioSessionOption>> GetCompletedSessionsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await portfolioStore.GetEligibleCompletedSessionsAsync(groupId, null);
|
||||
}
|
||||
|
||||
// --- Protected writes ---
|
||||
|
||||
public async Task<Guid> CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId)
|
||||
{
|
||||
await RequireManagerAsync(groupId);
|
||||
return await portfolioStore.CreatePortfolioDraftAsync(groupId, preselectedSessionId);
|
||||
}
|
||||
|
||||
public async Task UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var normalized = NormalizeUpdate(update);
|
||||
await portfolioStore.UpdatePortfolioDraftAsync(portfolioGameId, groupId, normalized);
|
||||
}
|
||||
|
||||
public async Task ReplaceCoverForCurrentUserAsync(
|
||||
Guid portfolioGameId,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var saveResult = await coverStorage.SaveAsync(content, contentType, cancellationToken);
|
||||
var newKey = saveResult.StorageKey;
|
||||
|
||||
try
|
||||
{
|
||||
var oldKey = await portfolioStore.SetPortfolioCoverAsync(portfolioGameId, groupId, newKey);
|
||||
if (!string.IsNullOrWhiteSpace(oldKey))
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(oldKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(newKey, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteForCurrentUserAsync(Guid portfolioGameId)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var coverKey = await portfolioStore.DeletePortfolioGameAsync(portfolioGameId, groupId);
|
||||
if (!string.IsNullOrWhiteSpace(coverKey))
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(coverKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
await portfolioStore.SetPortfolioPublicationAsync(portfolioGameId, groupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task ModerateReviewForCurrentUserAsync(
|
||||
Guid portfolioGameId,
|
||||
Guid reviewId,
|
||||
string moderationStatus)
|
||||
{
|
||||
var (groupId, platform, externalUserId) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var moderatorPlayerId = await sessionStore.ResolveEffectivePlayerIdAsync(platform, externalUserId);
|
||||
if (moderatorPlayerId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authenticated player not found.");
|
||||
}
|
||||
|
||||
await portfolioStore.ModeratePortfolioReviewAsync(
|
||||
reviewId,
|
||||
portfolioGameId,
|
||||
groupId,
|
||||
moderatorPlayerId.Value,
|
||||
moderationStatus);
|
||||
}
|
||||
|
||||
// --- Review submission ---
|
||||
|
||||
public async Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateForCurrentUserAsync(string slug)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return PortfolioReviewSubmissionState.RequiresAuthentication;
|
||||
}
|
||||
|
||||
return await portfolioStore.GetReviewSubmissionStateAsync(slug, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
public async Task SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent)
|
||||
{
|
||||
if (!publicationConsent)
|
||||
{
|
||||
throw new InvalidOperationException("Public review requires explicit consent.");
|
||||
}
|
||||
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(Guid.Empty, "<anonymous>");
|
||||
}
|
||||
|
||||
var normalizedSlug = PortfolioValidation.NormalizeSlug(slug);
|
||||
var normalizedBody = PortfolioValidation.NormalizeReviewBody(body);
|
||||
|
||||
var displayName = identity.Value.Name?.Trim() ?? identity.Value.ExternalUserId;
|
||||
if (displayName.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Display name is required.");
|
||||
}
|
||||
|
||||
await portfolioStore.SubmitPortfolioReviewAsync(
|
||||
normalizedSlug,
|
||||
identity.Value.Platform,
|
||||
identity.Value.ExternalUserId,
|
||||
displayName,
|
||||
normalizedBody);
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private static PortfolioGameUpdate NormalizeUpdate(PortfolioGameUpdate update)
|
||||
{
|
||||
var title = PortfolioValidation.NormalizeTitle(update.Title);
|
||||
var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug);
|
||||
var description = PortfolioValidation.NormalizeDescription(update.Description);
|
||||
var format = PortfolioValidation.NormalizeFormat(update.Format);
|
||||
var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim();
|
||||
|
||||
return update with
|
||||
{
|
||||
Title = title,
|
||||
PublicSlug = slug,
|
||||
Description = description,
|
||||
System = system,
|
||||
Format = format
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType);
|
||||
|
||||
public interface IPortfolioCoverStorage
|
||||
{
|
||||
Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default);
|
||||
|
||||
string GetPublicPath(string storageKey);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed class LocalPortfolioCoverStorage : IPortfolioCoverStorage
|
||||
{
|
||||
public const long MaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Regex SafeKeyPattern = new(
|
||||
"^[a-f0-9]{32}\\.(jpg|png|webp)$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];
|
||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
private static readonly byte[] RiffMarker = "RIFF"u8.ToArray();
|
||||
private static readonly byte[] WebpMarker = "WEBP"u8.ToArray();
|
||||
|
||||
private readonly string _storagePath;
|
||||
private readonly ILogger<LocalPortfolioCoverStorage> _logger;
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options)
|
||||
: this(options, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger<LocalPortfolioCoverStorage>? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.StoragePath))
|
||||
{
|
||||
throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured.");
|
||||
}
|
||||
|
||||
_storagePath = options.StoragePath;
|
||||
_logger = logger ?? NullLogger<LocalPortfolioCoverStorage>.Instance;
|
||||
}
|
||||
|
||||
public async Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
throw new InvalidOperationException("Content type must be provided.");
|
||||
}
|
||||
|
||||
var extension = NormalizeExtension(contentType);
|
||||
|
||||
// Buffer the stream so we can reject oversize uploads before writing to disk
|
||||
// and so we have the bytes we need for signature validation.
|
||||
await using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken);
|
||||
if (buffer.Length > MaxBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cover image exceeds the {MaxBytes}-byte size limit.");
|
||||
}
|
||||
|
||||
var signature = buffer.GetBuffer();
|
||||
var signatureLength = (int)buffer.Length;
|
||||
ValidateSignature(extension, signature, signatureLength);
|
||||
|
||||
Directory.CreateDirectory(_storagePath);
|
||||
var finalName = Guid.NewGuid().ToString("N") + extension;
|
||||
var finalPath = Path.Combine(_storagePath, finalName);
|
||||
var tempPath = finalPath + ".tmp";
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None))
|
||||
{
|
||||
buffer.Position = 0;
|
||||
await buffer.CopyToAsync(tempStream, cancellationToken);
|
||||
await tempStream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(tempPath, finalPath, overwrite: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new PortfolioCoverUploadResult(finalName, ResolveContentType(extension));
|
||||
}
|
||||
|
||||
public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
EnsureSafeKey(storageKey);
|
||||
|
||||
var path = Path.Combine(_storagePath, storageKey);
|
||||
TryDelete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string GetPublicPath(string storageKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
return "/portfolio-covers/" + Uri.EscapeDataString(storageKey);
|
||||
}
|
||||
|
||||
private static void ValidateSignature(string extension, byte[] data, int length)
|
||||
{
|
||||
var isValid = extension switch
|
||||
{
|
||||
".jpg" => StartsWith(data, length, JpegSignature),
|
||||
".png" => StartsWith(data, length, PngSignature),
|
||||
".webp" => StartsWith(data, length, RiffMarker)
|
||||
&& ContainsAt(data, RiffMarker.Length + 4, WebpMarker),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cover signature does not match the declared content type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StartsWith(byte[] data, int length, byte[] prefix)
|
||||
{
|
||||
if (length < prefix.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
{
|
||||
if (data[i] != prefix[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContainsAt(byte[] data, int offset, byte[] needle)
|
||||
{
|
||||
if (offset + needle.Length > data.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < needle.Length; i++)
|
||||
{
|
||||
if (data[offset + i] != needle[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string contentType)
|
||||
{
|
||||
var normalized = contentType.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"image/jpeg" or "image/jpg" => ".jpg",
|
||||
"image/png" => ".png",
|
||||
"image/webp" => ".webp",
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported cover content type: '{contentType}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
private static void EnsureSafeKey(string storageKey)
|
||||
{
|
||||
if (!SafeKeyPattern.IsMatch(storageKey))
|
||||
{
|
||||
throw new InvalidOperationException("Cover storage key is not in the expected format.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cover file '{Path}'.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public static class PortfolioCoverStorageExtensions
|
||||
{
|
||||
public static IServiceCollection AddPortfolioCoverStorage(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services
|
||||
.AddOptions<PortfolioCoverStorageOptions>()
|
||||
.Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName))
|
||||
.Validate(
|
||||
o => !string.IsNullOrWhiteSpace(o.StoragePath),
|
||||
"PortfolioCovers:StoragePath must be configured.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IPortfolioCoverStorage>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<
|
||||
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
|
||||
var logger = sp.GetService<ILoggerFactory>()?.CreateLogger<LocalPortfolioCoverStorage>()
|
||||
?? NullLogger<LocalPortfolioCoverStorage>.Instance;
|
||||
return new LocalPortfolioCoverStorage(options, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static WebApplication UsePortfolioCoverFiles(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
var options = app.Services.GetRequiredService<
|
||||
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
|
||||
|
||||
var storagePath = Path.IsPathRooted(options.StoragePath)
|
||||
? options.StoragePath
|
||||
: Path.Combine(app.Environment.ContentRootPath, options.StoragePath);
|
||||
|
||||
Directory.CreateDirectory(storagePath);
|
||||
|
||||
var contentTypeProvider = new FileExtensionContentTypeProvider();
|
||||
if (!contentTypeProvider.Mappings.ContainsKey(".jpg"))
|
||||
{
|
||||
contentTypeProvider.Mappings[".jpg"] = "image/jpeg";
|
||||
}
|
||||
|
||||
if (!contentTypeProvider.Mappings.ContainsKey(".png"))
|
||||
{
|
||||
contentTypeProvider.Mappings[".png"] = "image/png";
|
||||
}
|
||||
|
||||
if (!contentTypeProvider.Mappings.ContainsKey(".webp"))
|
||||
{
|
||||
contentTypeProvider.Mappings[".webp"] = "image/webp";
|
||||
}
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(storagePath),
|
||||
RequestPath = "/portfolio-covers",
|
||||
ContentTypeProvider = contentTypeProvider,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed class PortfolioCoverStorageOptions
|
||||
{
|
||||
public const string SectionName = "PortfolioCovers";
|
||||
|
||||
public string StoragePath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public interface IPortfolioStore
|
||||
{
|
||||
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
|
||||
|
||||
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
|
||||
|
||||
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
|
||||
|
||||
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
|
||||
|
||||
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
|
||||
|
||||
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
|
||||
|
||||
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||
|
||||
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||
|
||||
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
|
||||
|
||||
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
|
||||
|
||||
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
|
||||
|
||||
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
|
||||
|
||||
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
|
||||
|
||||
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
|
||||
|
||||
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
|
||||
|
||||
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public sealed record PublicPortfolioCard(
|
||||
string Slug,
|
||||
string Title,
|
||||
string CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt);
|
||||
|
||||
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
|
||||
|
||||
public sealed record PublicPortfolioReview(
|
||||
string AuthorDisplayName,
|
||||
string Body,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record PublicPortfolioGame(
|
||||
string Slug,
|
||||
string Title,
|
||||
string Description,
|
||||
string CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt,
|
||||
string? ClubName,
|
||||
string? ClubSlug,
|
||||
IReadOnlyList<PublicPortfolioMaster> Masters,
|
||||
IReadOnlyList<PublicPortfolioReview> Reviews);
|
||||
|
||||
public sealed record PortfolioGameSummary(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
bool IsPublic,
|
||||
DateTime CompletedAt,
|
||||
int SessionCount,
|
||||
int MasterCount,
|
||||
int PendingReviewCount);
|
||||
|
||||
public sealed record PortfolioSessionOption(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
bool Selected);
|
||||
|
||||
public sealed record PortfolioMasterOption(
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
bool Selected);
|
||||
|
||||
public sealed record PortfolioReviewForModeration(
|
||||
Guid Id,
|
||||
string AuthorDisplayName,
|
||||
string Body,
|
||||
string ModerationStatus,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record PortfolioGameEditor(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
string? Description,
|
||||
string? CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt,
|
||||
bool IsPublic,
|
||||
IReadOnlyList<PortfolioSessionOption> Sessions,
|
||||
IReadOnlyList<PortfolioMasterOption> Masters,
|
||||
IReadOnlyList<PortfolioReviewForModeration> Reviews);
|
||||
|
||||
public sealed record PortfolioGameUpdate(
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
string? Description,
|
||||
string? System,
|
||||
string? Format,
|
||||
IReadOnlyList<Guid> SessionIds,
|
||||
IReadOnlyList<Guid> MasterPlayerIds);
|
||||
|
||||
public enum PortfolioReviewSubmissionState
|
||||
{
|
||||
RequiresAuthentication,
|
||||
Ineligible,
|
||||
Eligible,
|
||||
AlreadySubmitted
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public static class PortfolioValidation
|
||||
{
|
||||
private const int MinSlugLength = 3;
|
||||
private const int MaxSlugLength = 160;
|
||||
private const int MinTitleLength = 2;
|
||||
private const int MaxTitleLength = 255;
|
||||
private const int MaxDescriptionLength = 5000;
|
||||
private const int MinReviewBodyLength = 10;
|
||||
private const int MaxReviewBodyLength = 2000;
|
||||
|
||||
private static readonly HashSet<string> AllowedFormats = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Online",
|
||||
"Offline",
|
||||
"Hybrid"
|
||||
};
|
||||
|
||||
public static string NormalizeSlug(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Slug must not be empty.");
|
||||
}
|
||||
|
||||
var trimmed = value.Trim().ToLowerInvariant();
|
||||
|
||||
var builder = new StringBuilder(trimmed.Length);
|
||||
var previousWasHyphen = false;
|
||||
foreach (var raw in trimmed)
|
||||
{
|
||||
char c;
|
||||
if (raw == ' ' || raw == '_' || raw == '-')
|
||||
{
|
||||
c = '-';
|
||||
}
|
||||
else if (IsAsciiAlphanumeric(raw))
|
||||
{
|
||||
c = raw;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Slug contains unsupported character: '{raw}'.");
|
||||
}
|
||||
|
||||
if (c == '-')
|
||||
{
|
||||
if (builder.Length == 0 || previousWasHyphen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('-');
|
||||
previousWasHyphen = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(c);
|
||||
previousWasHyphen = false;
|
||||
}
|
||||
}
|
||||
|
||||
while (builder.Length > 0 && builder[^1] == '-')
|
||||
{
|
||||
builder.Length--;
|
||||
}
|
||||
|
||||
if (builder.Length < MinSlugLength || builder.Length > MaxSlugLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Slug length must be between {MinSlugLength} and {MaxSlugLength} characters.");
|
||||
}
|
||||
|
||||
// The normalization loop guarantees the output matches ^[a-z0-9]+(?:-[a-z0-9]+)*$,
|
||||
// so no post-loop regex check is required.
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static string NormalizeTitle(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Title must not be empty.");
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < MinTitleLength || trimmed.Length > MaxTitleLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Title length must be between {MinTitleLength} and {MaxTitleLength} characters.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static string? NormalizeDescription(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length > MaxDescriptionLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Description must be at most {MaxDescriptionLength} characters.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static string NormalizeReviewBody(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Review body must not be empty.");
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < MinReviewBodyLength || trimmed.Length > MaxReviewBodyLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Review body length must be between {MinReviewBodyLength} and {MaxReviewBodyLength} characters.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static string? NormalizeFormat(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (!AllowedFormats.Contains(trimmed))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Format must be one of: {string.Join(", ", AllowedFormats)}.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphanumeric(char c) =>
|
||||
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
||||
}
|
||||
@@ -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.created_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.created_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.created_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.created_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.created_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();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"PortfolioCovers": {
|
||||
"StoragePath": "../../artifacts/portfolio-covers"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1397,6 +1449,62 @@ body.telegram-mini-app .session-card-mobile {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
/* === Identity list (profile page) === */
|
||||
.identity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 1rem;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.identity-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.identity-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.identity-platform {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
}
|
||||
|
||||
.telegram-widget-wrapper {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* === Sidebar refinements (MainLayout & NavMenu) === */
|
||||
.page {
|
||||
display: flex;
|
||||
@@ -1795,6 +1903,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;
|
||||
@@ -1895,3 +2021,427 @@ body.telegram-mini-app .session-card-mobile {
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* === Portfolio Management === */
|
||||
.portfolio-management-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-management-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.6fr) auto;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portfolio-management-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portfolio-management-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.portfolio-management-title:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.portfolio-management-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portfolio-management-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.portfolio-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr);
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-editor-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.portfolio-editor-cover-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.portfolio-editor-cover-empty {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portfolio-editor-cover-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.portfolio-editor-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portfolio-editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-editor-publish-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.portfolio-option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-option-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.portfolio-option-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.portfolio-option-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.portfolio-review-moderation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-review-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portfolio-review-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-review-author {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portfolio-review-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.portfolio-review-body {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.portfolio-review-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-completed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-completed-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portfolio-completed-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portfolio-completed-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.portfolio-completed-title:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.portfolio-completed-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.portfolio-completed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.portfolio-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portfolio-management-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portfolio-management-actions,
|
||||
.portfolio-completed-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.portfolio-option-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.portfolio-option-meta {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portfolio-completed-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Public Portfolio === */
|
||||
.portfolio-section {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portfolio-section h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.portfolio-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.portfolio-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.portfolio-card-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: var(--bg-secondary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.portfolio-card-cover-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.portfolio-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.portfolio-card-body h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.portfolio-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portfolio-card-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portfolio-card-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portfolio-cover-hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 7;
|
||||
background-color: var(--bg-secondary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.portfolio-review-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portfolio-review-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portfolio-review-textarea {
|
||||
width: 100%;
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.portfolio-review-textarea:focus {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.portfolio-review-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.portfolio-review-error {
|
||||
margin: 0;
|
||||
color: var(--status-error, #ff6b6b);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.portfolio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portfolio-cover-hero {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(
|
||||
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// 15:00 MSK = 12:00 UTC
|
||||
Assert.Equal(12, result.Value.Hour);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Web.Services;
|
||||
using GmRelay.Web.Services.Portfolio;
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class AuthorizedPortfolioServiceTests
|
||||
{
|
||||
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, externalUserId),
|
||||
new Claim("TelegramId", externalUserId),
|
||||
new Claim("Platform", "Telegram")
|
||||
};
|
||||
if (name is not null)
|
||||
claims.Add(new Claim(ClaimTypes.Name, name));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
return new HttpContextAccessor { HttpContext = httpContext };
|
||||
}
|
||||
|
||||
private static IHttpContextAccessor CreateAnonymousAccessor()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
return new HttpContextAccessor { HttpContext = httpContext };
|
||||
}
|
||||
|
||||
private static AuthorizedPortfolioService CreateService(
|
||||
FakePortfolioStore? portfolioStore = null,
|
||||
FakeSessionStore? sessionStore = null,
|
||||
FakePortfolioCoverStorage? coverStorage = null,
|
||||
IHttpContextAccessor? accessor = null,
|
||||
bool isManager = true,
|
||||
Guid? knownGroupId = null)
|
||||
{
|
||||
portfolioStore ??= new FakePortfolioStore();
|
||||
sessionStore ??= new FakeSessionStore();
|
||||
coverStorage ??= new FakePortfolioCoverStorage();
|
||||
accessor ??= CreateAccessor("1001");
|
||||
|
||||
// Wire a known group + manager relationship for the test
|
||||
if (knownGroupId is not null)
|
||||
{
|
||||
portfolioStore.GroupIds[Guid.NewGuid()] = knownGroupId.Value; // placeholder
|
||||
sessionStore.ManagerFlags[(knownGroupId.Value, "Telegram", "1001")] = isManager;
|
||||
}
|
||||
|
||||
return new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var draftId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
CreateDraftResult = draftId,
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?>
|
||||
{
|
||||
[draftId] = groupId
|
||||
}
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId);
|
||||
|
||||
Assert.Equal(draftId, created);
|
||||
Assert.Equal(groupId, portfolioStore.LastCreateGroupId);
|
||||
Assert.Equal(sessionId, portfolioStore.LastCreatePreselectedSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = false
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(new FakePortfolioStore(), sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
||||
() => service.CreateDraftForCurrentUserAsync(groupId, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
||||
CoverPriorKey = "old.png"
|
||||
};
|
||||
var coverStorage = new FakePortfolioCoverStorage
|
||||
{
|
||||
SaveKey = "new.png"
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
||||
|
||||
await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png");
|
||||
|
||||
Assert.Contains("old.png", coverStorage.DeletedKeys);
|
||||
Assert.Contains("new.png", coverStorage.SavedKeys);
|
||||
Assert.Equal(portfolioGameId, portfolioStore.LastSetCoverGameId);
|
||||
Assert.Equal("new.png", portfolioStore.LastSetCoverKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
||||
SetCoverThrows = new InvalidOperationException("boom")
|
||||
};
|
||||
var coverStorage = new FakePortfolioCoverStorage
|
||||
{
|
||||
SaveKey = "new.png"
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png"));
|
||||
|
||||
Assert.Contains("new.png", coverStorage.DeletedKeys);
|
||||
Assert.DoesNotContain("old.png", coverStorage.DeletedKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnNullForAnotherClubManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = false
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);
|
||||
|
||||
Assert.Null(editor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnEditorForCoGm()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
||||
EditorResult = new PortfolioGameEditor(
|
||||
portfolioGameId,
|
||||
groupId,
|
||||
"Title",
|
||||
"slug",
|
||||
"Description",
|
||||
"/portfolio-covers/x.png",
|
||||
"D&D 5e",
|
||||
"Online",
|
||||
DateTime.UtcNow,
|
||||
false,
|
||||
[],
|
||||
[],
|
||||
[])
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);
|
||||
|
||||
Assert.NotNull(editor);
|
||||
Assert.Equal("Title", editor!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = false
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
var update = new PortfolioGameUpdate("Updated", "updated-slug", "desc", null, "Online", [], []);
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
||||
() => service.UpdateDraftForCurrentUserAsync(portfolioGameId, update));
|
||||
Assert.False(portfolioStore.UpdateCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraftForCurrentUserAsync_ShouldNormalizeFieldsBeforeStoring()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
var update = new PortfolioGameUpdate(" Updated ", " my-slug ", " description ", null, " Online ", [], []);
|
||||
|
||||
await service.UpdateDraftForCurrentUserAsync(portfolioGameId, update);
|
||||
|
||||
Assert.True(portfolioStore.UpdateCalled);
|
||||
Assert.Equal("Updated", portfolioStore.LastUpdateTitle);
|
||||
Assert.Equal("my-slug", portfolioStore.LastUpdateSlug);
|
||||
Assert.Equal("description", portfolioStore.LastUpdateDescription);
|
||||
Assert.Equal("Online", portfolioStore.LastUpdateFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ModerateReviewForCurrentUserAsync_ShouldResolveEffectivePlayerAndForwardIt()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var reviewId = Guid.NewGuid();
|
||||
var effectivePlayerId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
},
|
||||
EffectivePlayerId = effectivePlayerId
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await service.ModerateReviewForCurrentUserAsync(portfolioGameId, reviewId, "Approved");
|
||||
|
||||
Assert.True(portfolioStore.ModerateCalled);
|
||||
Assert.Equal(reviewId, portfolioStore.LastModerateReviewId);
|
||||
Assert.Equal(portfolioGameId, portfolioStore.LastModerateGameId);
|
||||
Assert.Equal(groupId, portfolioStore.LastModerateGroupId);
|
||||
Assert.Equal(effectivePlayerId, portfolioStore.LastModeratePlayerId);
|
||||
Assert.Equal("Approved", portfolioStore.LastModerateStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ModerateReviewForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = false
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
||||
() => service.ModerateReviewForCurrentUserAsync(portfolioGameId, Guid.NewGuid(), "Approved"));
|
||||
Assert.False(portfolioStore.ModerateCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteForCurrentUserAsync_ShouldDeleteCoverAfterRowDeletion()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
||||
DeleteCoverKey = "old.png"
|
||||
};
|
||||
var coverStorage = new FakePortfolioCoverStorage();
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
||||
|
||||
await service.DeleteForCurrentUserAsync(portfolioGameId);
|
||||
|
||||
Assert.True(portfolioStore.DeleteCalled);
|
||||
Assert.Equal(portfolioGameId, portfolioStore.LastDeleteGameId);
|
||||
Assert.Equal(groupId, portfolioStore.LastDeleteGroupId);
|
||||
Assert.Contains("old.png", coverStorage.DeletedKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteForCurrentUserAsync_ShouldStillDeleteRowWhenNoCover()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
||||
DeleteCoverKey = null
|
||||
};
|
||||
var coverStorage = new FakePortfolioCoverStorage();
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
||||
|
||||
await service.DeleteForCurrentUserAsync(portfolioGameId);
|
||||
|
||||
Assert.True(portfolioStore.DeleteCalled);
|
||||
Assert.Empty(coverStorage.DeletedKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPublicationForCurrentUserAsync_ShouldForwardIsPublicFlag()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await service.SetPublicationForCurrentUserAsync(portfolioGameId, isPublic: true);
|
||||
|
||||
Assert.True(portfolioStore.PublicationCalled);
|
||||
Assert.Equal(portfolioGameId, portfolioStore.LastPublicationGameId);
|
||||
Assert.Equal(groupId, portfolioStore.LastPublicationGroupId);
|
||||
Assert.True(portfolioStore.LastPublicationIsPublic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldReturnRequiresAuthForAnonymous()
|
||||
{
|
||||
var portfolioStore = new FakePortfolioStore();
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAnonymousAccessor();
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");
|
||||
|
||||
Assert.Equal(PortfolioReviewSubmissionState.RequiresAuthentication, state);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldForwardPlatformAndUserId()
|
||||
{
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
ReviewStateResult = PortfolioReviewSubmissionState.Eligible
|
||||
};
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");
|
||||
|
||||
Assert.Equal(PortfolioReviewSubmissionState.Eligible, state);
|
||||
Assert.Equal("some-slug", portfolioStore.LastReviewStateSlug);
|
||||
Assert.Equal("Telegram", portfolioStore.LastReviewStatePlatform);
|
||||
Assert.Equal("1001", portfolioStore.LastReviewStateExternalUserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitReviewForCurrentUserAsync_ShouldRejectAnonymous()
|
||||
{
|
||||
var portfolioStore = new FakePortfolioStore();
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAnonymousAccessor();
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
||||
() => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", true));
|
||||
Assert.False(portfolioStore.SubmitReviewCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitReviewForCurrentUserAsync_ShouldRejectMissingConsent()
|
||||
{
|
||||
var portfolioStore = new FakePortfolioStore();
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAccessor("1001", "Alice");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", false));
|
||||
Assert.False(portfolioStore.SubmitReviewCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitReviewForCurrentUserAsync_ShouldNormalizeBodyAndForwardIdentity()
|
||||
{
|
||||
var portfolioStore = new FakePortfolioStore();
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAccessor("1001", "Alice");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await service.SubmitReviewForCurrentUserAsync(
|
||||
" the-curse-of-strahd ",
|
||||
" great adventure, would play again ",
|
||||
true);
|
||||
|
||||
Assert.True(portfolioStore.SubmitReviewCalled);
|
||||
Assert.Equal("the-curse-of-strahd", portfolioStore.LastSubmitSlug);
|
||||
Assert.Equal("great adventure, would play again", portfolioStore.LastSubmitBody);
|
||||
Assert.Equal("Alice", portfolioStore.LastSubmitDisplayName);
|
||||
Assert.Equal("Telegram", portfolioStore.LastSubmitPlatform);
|
||||
Assert.Equal("1001", portfolioStore.LastSubmitExternalUserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnEmptyForNonManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore();
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = false
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);
|
||||
|
||||
Assert.Empty(sessions);
|
||||
Assert.Null(portfolioStore.LastEligibleGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnSessionsForManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
EligibleSessions =
|
||||
[
|
||||
new PortfolioSessionOption(sessionId, "Old session", DateTime.UtcNow.AddDays(-7), false)
|
||||
]
|
||||
};
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = true
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);
|
||||
|
||||
Assert.Single(sessions);
|
||||
Assert.Equal(sessionId, sessions[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPortfolioGamesForCurrentUserAsync_ShouldReturnEmptyForNonManager()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore();
|
||||
var sessionStore = new FakeSessionStore
|
||||
{
|
||||
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
||||
{
|
||||
[(groupId, "Telegram", "1001")] = false
|
||||
}
|
||||
};
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
var games = await service.GetPortfolioGamesForCurrentUserAsync(groupId);
|
||||
|
||||
Assert.Empty(games);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IDScopedMethod_ShouldThrowWhenPortfolioGameDoesNotExist()
|
||||
{
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var portfolioStore = new FakePortfolioStore
|
||||
{
|
||||
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = null }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.DeleteForCurrentUserAsync(portfolioGameId));
|
||||
}
|
||||
|
||||
// --- Fakes ---
|
||||
|
||||
private sealed class FakePortfolioStore : IPortfolioStore
|
||||
{
|
||||
public Dictionary<Guid, Guid?> PortfolioGameGroupIds { get; set; } = new();
|
||||
|
||||
public Dictionary<Guid, Guid> GroupIds { get; set; } = new();
|
||||
|
||||
public Guid CreateDraftResult { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid? LastCreateGroupId { get; private set; }
|
||||
public Guid? LastCreatePreselectedSessionId { get; private set; }
|
||||
public bool CreateCalled { get; private set; }
|
||||
|
||||
public PortfolioGameEditor? EditorResult { get; set; }
|
||||
|
||||
public string? CoverPriorKey { get; set; }
|
||||
public Exception? SetCoverThrows { get; set; }
|
||||
public Guid? LastSetCoverGameId { get; private set; }
|
||||
public Guid? LastSetCoverGroupId { get; private set; }
|
||||
public string? LastSetCoverKey { get; private set; }
|
||||
|
||||
public bool UpdateCalled { get; private set; }
|
||||
public Guid? LastUpdateGameId { get; private set; }
|
||||
public Guid? LastUpdateGroupId { get; private set; }
|
||||
public string? LastUpdateTitle { get; private set; }
|
||||
public string? LastUpdateSlug { get; private set; }
|
||||
public string? LastUpdateDescription { get; private set; }
|
||||
public string? LastUpdateFormat { get; private set; }
|
||||
|
||||
public bool DeleteCalled { get; private set; }
|
||||
public Guid? LastDeleteGameId { get; private set; }
|
||||
public Guid? LastDeleteGroupId { get; private set; }
|
||||
public string? DeleteCoverKey { get; set; }
|
||||
|
||||
public bool PublicationCalled { get; private set; }
|
||||
public Guid? LastPublicationGameId { get; private set; }
|
||||
public Guid? LastPublicationGroupId { get; private set; }
|
||||
public bool? LastPublicationIsPublic { get; private set; }
|
||||
|
||||
public bool ModerateCalled { get; private set; }
|
||||
public Guid? LastModerateReviewId { get; private set; }
|
||||
public Guid? LastModerateGameId { get; private set; }
|
||||
public Guid? LastModerateGroupId { get; private set; }
|
||||
public Guid? LastModeratePlayerId { get; private set; }
|
||||
public string? LastModerateStatus { get; private set; }
|
||||
|
||||
public IReadOnlyList<PortfolioSessionOption> EligibleSessions { get; set; } = [];
|
||||
public Guid? LastEligibleGroupId { get; private set; }
|
||||
|
||||
public IReadOnlyList<PortfolioGameSummary> GamesForGroup { get; set; } = [];
|
||||
|
||||
public PortfolioReviewSubmissionState ReviewStateResult { get; set; } = PortfolioReviewSubmissionState.Ineligible;
|
||||
public string? LastReviewStateSlug { get; private set; }
|
||||
public string? LastReviewStatePlatform { get; private set; }
|
||||
public string? LastReviewStateExternalUserId { get; private set; }
|
||||
|
||||
public bool SubmitReviewCalled { get; private set; }
|
||||
public string? LastSubmitSlug { get; private set; }
|
||||
public string? LastSubmitBody { get; private set; }
|
||||
public string? LastSubmitDisplayName { get; private set; }
|
||||
public string? LastSubmitPlatform { get; private set; }
|
||||
public string? LastSubmitExternalUserId { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug) =>
|
||||
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
|
||||
|
||||
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug) =>
|
||||
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
|
||||
|
||||
public Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug) =>
|
||||
Task.FromResult<PublicPortfolioGame?>(null);
|
||||
|
||||
public Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
|
||||
{
|
||||
LastEligibleGroupId = groupId;
|
||||
return Task.FromResult(GamesForGroup);
|
||||
}
|
||||
|
||||
public Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
|
||||
{
|
||||
PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId);
|
||||
return Task.FromResult(groupId);
|
||||
}
|
||||
|
||||
public Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId) =>
|
||||
Task.FromResult(EditorResult);
|
||||
|
||||
public Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
|
||||
{
|
||||
LastEligibleGroupId = groupId;
|
||||
return Task.FromResult(EligibleSessions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) =>
|
||||
Task.FromResult<IReadOnlyList<PortfolioMasterOption>>([]);
|
||||
|
||||
public Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId)
|
||||
{
|
||||
CreateCalled = true;
|
||||
LastCreateGroupId = groupId;
|
||||
LastCreatePreselectedSessionId = preselectedSessionId;
|
||||
return Task.FromResult(CreateDraftResult);
|
||||
}
|
||||
|
||||
public Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update)
|
||||
{
|
||||
UpdateCalled = true;
|
||||
LastUpdateGameId = portfolioGameId;
|
||||
LastUpdateGroupId = groupId;
|
||||
LastUpdateTitle = update.Title;
|
||||
LastUpdateSlug = update.PublicSlug;
|
||||
LastUpdateDescription = update.Description;
|
||||
LastUpdateFormat = update.Format;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey)
|
||||
{
|
||||
LastSetCoverGameId = portfolioGameId;
|
||||
LastSetCoverGroupId = groupId;
|
||||
LastSetCoverKey = storageKey;
|
||||
|
||||
if (SetCoverThrows is not null)
|
||||
{
|
||||
throw SetCoverThrows;
|
||||
}
|
||||
|
||||
return Task.FromResult(CoverPriorKey);
|
||||
}
|
||||
|
||||
public Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId)
|
||||
{
|
||||
DeleteCalled = true;
|
||||
LastDeleteGameId = portfolioGameId;
|
||||
LastDeleteGroupId = groupId;
|
||||
return Task.FromResult(DeleteCoverKey);
|
||||
}
|
||||
|
||||
public Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic)
|
||||
{
|
||||
PublicationCalled = true;
|
||||
LastPublicationGameId = portfolioGameId;
|
||||
LastPublicationGroupId = groupId;
|
||||
LastPublicationIsPublic = isPublic;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ModeratePortfolioReviewAsync(
|
||||
Guid reviewId,
|
||||
Guid portfolioGameId,
|
||||
Guid groupId,
|
||||
Guid moderatorPlayerId,
|
||||
string moderationStatus)
|
||||
{
|
||||
ModerateCalled = true;
|
||||
LastModerateReviewId = reviewId;
|
||||
LastModerateGameId = portfolioGameId;
|
||||
LastModerateGroupId = groupId;
|
||||
LastModeratePlayerId = moderatorPlayerId;
|
||||
LastModerateStatus = moderationStatus;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId)
|
||||
{
|
||||
LastReviewStateSlug = slug;
|
||||
LastReviewStatePlatform = platform;
|
||||
LastReviewStateExternalUserId = externalUserId;
|
||||
return Task.FromResult(ReviewStateResult);
|
||||
}
|
||||
|
||||
public Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body)
|
||||
{
|
||||
SubmitReviewCalled = true;
|
||||
LastSubmitSlug = slug;
|
||||
LastSubmitPlatform = platform;
|
||||
LastSubmitExternalUserId = externalUserId;
|
||||
LastSubmitDisplayName = displayName;
|
||||
LastSubmitBody = body;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSessionStore : ISessionStore
|
||||
{
|
||||
public Dictionary<(Guid GroupId, string Platform, string ExternalUserId), bool> ManagerFlags { get; set; } = new();
|
||||
|
||||
public Guid? EffectivePlayerId { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||
{
|
||||
if (ManagerFlags.TryGetValue((groupId, platform, externalUserId), out var flag))
|
||||
{
|
||||
return Task.FromResult(flag);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult(EffectivePlayerId);
|
||||
|
||||
// Unused interface members — throw so accidental use surfaces in test output
|
||||
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
|
||||
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException();
|
||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException();
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException();
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId) => throw new NotImplementedException();
|
||||
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException();
|
||||
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
|
||||
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException();
|
||||
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException();
|
||||
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) => throw new NotImplementedException();
|
||||
public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException();
|
||||
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException();
|
||||
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException();
|
||||
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) => throw new NotImplementedException();
|
||||
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) => throw new NotImplementedException();
|
||||
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) => throw new NotImplementedException();
|
||||
public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException();
|
||||
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) => throw new NotImplementedException();
|
||||
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
|
||||
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException();
|
||||
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException();
|
||||
public Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException();
|
||||
public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException();
|
||||
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto>> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException();
|
||||
public Task<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class FakePortfolioCoverStorage : IPortfolioCoverStorage
|
||||
{
|
||||
public string SaveKey { get; set; } = Guid.NewGuid().ToString("N") + ".png";
|
||||
public List<string> SavedKeys { get; } = new();
|
||||
public List<string> DeletedKeys { get; } = new();
|
||||
|
||||
public Task<PortfolioCoverUploadResult> SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SavedKeys.Add(SaveKey);
|
||||
return Task.FromResult(new PortfolioCoverUploadResult(SaveKey, contentType));
|
||||
}
|
||||
|
||||
public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeletedKeys.Add(storageKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string GetPublicPath(string storageKey) => "/portfolio-covers/" + storageKey;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class CampaignTemplatesNavigationTests
|
||||
@@ -17,14 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
|
||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||
{
|
||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||
var props = XDocument.Load(FindRepositoryFile("Directory.Build.props"));
|
||||
var version = props.Root?
|
||||
.Element("PropertyGroup")?
|
||||
.Element("Version")?
|
||||
.Value;
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||
Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class LocalPortfolioCoverStorageTests : IDisposable
|
||||
{
|
||||
private readonly string _storagePath;
|
||||
private readonly LocalPortfolioCoverStorage _storage;
|
||||
|
||||
public LocalPortfolioCoverStorageTests()
|
||||
{
|
||||
_storagePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"gmrelay-portfolio-covers-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_storagePath);
|
||||
_storage = new LocalPortfolioCoverStorage(new PortfolioCoverStorageOptions
|
||||
{
|
||||
StoragePath = _storagePath
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_storagePath))
|
||||
{
|
||||
Directory.Delete(_storagePath, recursive: true);
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]);
|
||||
|
||||
var result = await _storage.SaveAsync(stream, "image/png");
|
||||
|
||||
Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal);
|
||||
Assert.StartsWith("/portfolio-covers/", _storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal);
|
||||
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("image/jpeg")]
|
||||
[InlineData("image/png")]
|
||||
[InlineData("image/webp")]
|
||||
public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType)
|
||||
{
|
||||
await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _storage.SaveAsync(stream, contentType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldPersistJpegWithCorrectSignature()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]);
|
||||
|
||||
var result = await _storage.SaveAsync(stream, "image/jpeg");
|
||||
|
||||
Assert.EndsWith(".jpg", result.StorageKey, StringComparison.Ordinal);
|
||||
Assert.Equal("image/jpeg", result.ContentType);
|
||||
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldPersistWebpWithRiffWebpSignature()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
[0x52, 0x49, 0x46, 0x46, 0x1A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38]);
|
||||
|
||||
var result = await _storage.SaveAsync(stream, "image/webp");
|
||||
|
||||
Assert.EndsWith(".webp", result.StorageKey, StringComparison.Ordinal);
|
||||
Assert.Equal("image/webp", result.ContentType);
|
||||
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldRejectStreamLargerThanMaxBytes()
|
||||
{
|
||||
var oversized = new byte[LocalPortfolioCoverStorage.MaxBytes + 1];
|
||||
await using var stream = new MemoryStream(oversized);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _storage.SaveAsync(stream, "image/png"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldRejectUnknownContentType()
|
||||
{
|
||||
await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _storage.SaveAsync(stream, "application/octet-stream"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteIfExistsAsync_ShouldRemoveExistingKey()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
var result = await _storage.SaveAsync(stream, "image/png");
|
||||
var path = Path.Combine(_storagePath, result.StorageKey);
|
||||
Assert.True(File.Exists(path));
|
||||
|
||||
await _storage.DeleteIfExistsAsync(result.StorageKey);
|
||||
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteIfExistsAsync_ShouldBeNoOpForMissingKey()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString("N") + ".png";
|
||||
|
||||
await _storage.DeleteIfExistsAsync(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteIfExistsAsync_ShouldRejectPathTraversal()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _storage.DeleteIfExistsAsync("../escape.png"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _storage.DeleteIfExistsAsync(Guid.NewGuid().ToString("N") + ".gif"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPublicPath_ShouldEscapeSpecialCharacters()
|
||||
{
|
||||
var key = "0123456789abcdef0123456789abcdef" + ".png";
|
||||
|
||||
var path = _storage.GetPublicPath(key);
|
||||
|
||||
Assert.Equal("/portfolio-covers/" + Uri.EscapeDataString(key), path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldGenerateUniqueKeys()
|
||||
{
|
||||
await using var stream1 = new MemoryStream(
|
||||
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
await using var stream2 = new MemoryStream(
|
||||
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
|
||||
var first = await _storage.SaveAsync(stream1, "image/png");
|
||||
var second = await _storage.SaveAsync(stream2, "image/png");
|
||||
|
||||
Assert.NotEqual(first.StorageKey, second.StorageKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldNotLeaveTempFileBehind()
|
||||
{
|
||||
await using var stream = new MemoryStream(
|
||||
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
|
||||
await _storage.SaveAsync(stream, "image/png");
|
||||
|
||||
var tempFiles = Directory.GetFiles(_storagePath, "*.tmp");
|
||||
Assert.Empty(tempFiles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicOwnerProfileLinks_ShouldOrderByExistingGroupManagerTimestamp()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V008__add_group_managers.sql");
|
||||
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("created_at", migration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("added_at", migration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("gm.added_at", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
||||
Assert.Contains("gm.created_at", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string RecordSection(string source, string recordName)
|
||||
{
|
||||
var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf(");", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..(end + 2)];
|
||||
}
|
||||
|
||||
private static string PublicQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..end];
|
||||
}
|
||||
|
||||
private static string MethodSection(string source, string methodName)
|
||||
{
|
||||
var start = -1;
|
||||
var searchFrom = 0;
|
||||
while (searchFrom < source.Length)
|
||||
{
|
||||
var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal);
|
||||
if (candidate < 0)
|
||||
return string.Empty;
|
||||
|
||||
var lineStart = source.LastIndexOf('\n', candidate);
|
||||
var headerStart = lineStart < 0 ? 0 : lineStart + 1;
|
||||
var header = source[headerStart..candidate];
|
||||
if (header.Contains("private static async Task", StringComparison.Ordinal))
|
||||
{
|
||||
start = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
searchFrom = candidate + methodName.Length;
|
||||
}
|
||||
|
||||
var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal);
|
||||
if (nextMethod < 0)
|
||||
{
|
||||
nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return nextMethod < 0 ? source[start..] : source[start..nextMethod];
|
||||
}
|
||||
|
||||
private static string EndpointSection(string source, string route)
|
||||
{
|
||||
var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal);
|
||||
return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint];
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using GmRelay.Web.Services.Portfolio;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioContractsTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublicPortfolioCard_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioCard>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioGame_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioGame>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioMaster_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioMaster>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioReview_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioReview>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioCard_ShouldExposeExpectedProperties()
|
||||
{
|
||||
var names = typeof(PublicPortfolioCard).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.Contains("Slug", names);
|
||||
Assert.Contains("Title", names);
|
||||
Assert.Contains("CoverPath", names);
|
||||
Assert.Contains("System", names);
|
||||
Assert.Contains("Format", names);
|
||||
Assert.Contains("CompletedAt", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioGame_ShouldExposeExpectedProperties()
|
||||
{
|
||||
var names = typeof(PublicPortfolioGame).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.Contains("Slug", names);
|
||||
Assert.Contains("Title", names);
|
||||
Assert.Contains("Description", names);
|
||||
Assert.Contains("CoverPath", names);
|
||||
Assert.Contains("System", names);
|
||||
Assert.Contains("Format", names);
|
||||
Assert.Contains("CompletedAt", names);
|
||||
Assert.Contains("ClubName", names);
|
||||
Assert.Contains("ClubSlug", names);
|
||||
Assert.Contains("Masters", names);
|
||||
Assert.Contains("Reviews", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioMaster_ShouldExposeExpectedProperties()
|
||||
{
|
||||
var names = typeof(PublicPortfolioMaster).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.Contains("Slug", names);
|
||||
Assert.Contains("DisplayName", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioReview_ShouldExposeExpectedProperties()
|
||||
{
|
||||
var names = typeof(PublicPortfolioReview).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.Contains("AuthorDisplayName", names);
|
||||
Assert.Contains("Body", names);
|
||||
Assert.Contains("CreatedAt", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IPortfolioStore_ShouldExposeAllRequiredMethods()
|
||||
{
|
||||
var interfaceType = typeof(IPortfolioStore);
|
||||
var methodNames = interfaceType.GetMethods().Select(m => m.Name).ToArray();
|
||||
|
||||
Assert.Contains("GetPublicPortfolioGamesForMasterAsync", methodNames);
|
||||
Assert.Contains("GetPublicPortfolioGamesForClubAsync", methodNames);
|
||||
Assert.Contains("GetPublicPortfolioGameBySlugAsync", methodNames);
|
||||
Assert.Contains("GetPortfolioGamesForGroupAsync", methodNames);
|
||||
Assert.Contains("GetPortfolioGameGroupIdAsync", methodNames);
|
||||
Assert.Contains("GetPortfolioGameForManagementAsync", methodNames);
|
||||
Assert.Contains("GetEligibleCompletedSessionsAsync", methodNames);
|
||||
Assert.Contains("GetPortfolioMasterOptionsAsync", methodNames);
|
||||
Assert.Contains("CreatePortfolioDraftAsync", methodNames);
|
||||
Assert.Contains("UpdatePortfolioDraftAsync", methodNames);
|
||||
Assert.Contains("SetPortfolioCoverAsync", methodNames);
|
||||
Assert.Contains("DeletePortfolioGameAsync", methodNames);
|
||||
Assert.Contains("SetPortfolioPublicationAsync", methodNames);
|
||||
Assert.Contains("ModeratePortfolioReviewAsync", methodNames);
|
||||
Assert.Contains("GetReviewSubmissionStateAsync", methodNames);
|
||||
Assert.Contains("SubmitPortfolioReviewAsync", methodNames);
|
||||
}
|
||||
|
||||
private static void AssertNoForbiddenPropertyNames<T>()
|
||||
{
|
||||
var forbidden = new[]
|
||||
{
|
||||
"Id", "External", "Telegram", "Discord", "Moderator",
|
||||
"StorageKey", "PhysicalPath", "JoinLink", "Session"
|
||||
};
|
||||
|
||||
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
foreach (var forbiddenFragment in forbidden)
|
||||
{
|
||||
Assert.DoesNotContain(names, name => name.Contains(forbiddenFragment, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioCoverRuntimeWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Program_ShouldRegisterPortfolioCoverStorage()
|
||||
{
|
||||
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
|
||||
|
||||
Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal);
|
||||
Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compose_ShouldMountPortfolioCoversVolumeAndPassStoragePath()
|
||||
{
|
||||
var compose = await ReadRepositoryFileAsync("compose.yaml");
|
||||
|
||||
Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dockerfile_ShouldCreateAndChownPortfolioCoversDirectory()
|
||||
{
|
||||
var dockerfile = await ReadRepositoryFileAsync("src/GmRelay.Web/Dockerfile");
|
||||
|
||||
Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
|
||||
Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DevelopmentSettings_ShouldConfigurePortfolioCoversStoragePath()
|
||||
{
|
||||
var developmentSettings = await ReadRepositoryFileAsync("src/GmRelay.Web/appsettings.Development.json");
|
||||
|
||||
Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvExample_ShouldDocumentPortfolioCoversVolumeName()
|
||||
{
|
||||
var envExample = await ReadRepositoryFileAsync(".env.example");
|
||||
|
||||
Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", envExample, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compose_ShouldDeclarePortfolioCoversNamedVolume()
|
||||
{
|
||||
var compose = await ReadRepositoryFileAsync("compose.yaml");
|
||||
|
||||
Assert.Contains("portfolio_covers:", compose, StringComparison.Ordinal);
|
||||
Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", compose, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<PortfolioMigrationPostgresFixture>
|
||||
{
|
||||
public const string Name = "Portfolio migration PostgreSQL";
|
||||
}
|
||||
|
||||
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return container.StartAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public async Task<MigratedPortfolioDatabase> CreateMigratedDatabaseAsync()
|
||||
{
|
||||
var databaseName = $"portfolio_{Guid.NewGuid():N}";
|
||||
|
||||
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
|
||||
{
|
||||
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
|
||||
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
|
||||
{
|
||||
Database = databaseName,
|
||||
Timeout = 10,
|
||||
CommandTimeout = 10
|
||||
}.ConnectionString;
|
||||
|
||||
var migrations = GetMigrationPaths();
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
|
||||
foreach (var migration in migrations)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
|
||||
{
|
||||
CommandTimeout = 30
|
||||
};
|
||||
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
return new MigratedPortfolioDatabase(connectionString, migrations.Count);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetMigrationPaths()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
|
||||
if (Directory.Exists(migrationsDirectory))
|
||||
{
|
||||
return Directory.GetFiles(migrationsDirectory, "V*.sql")
|
||||
.Where(path => string.CompareOrdinal(Path.GetFileName(path), "V030__") < 0)
|
||||
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MigratedPortfolioDatabase(string ConnectionString, int AppliedMigrationCount)
|
||||
{
|
||||
public async Task<NpgsqlConnection> OpenConnectionAsync()
|
||||
{
|
||||
var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioMigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");
|
||||
var normalizedMigration = NormalizeSql(migration);
|
||||
var unpublishFunctionStart = normalizedMigration.IndexOf(
|
||||
"CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()",
|
||||
StringComparison.Ordinal);
|
||||
var unpublishFunctionEnd = normalizedMigration.IndexOf(
|
||||
"CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE portfolio_game_reviews", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (NOT is_public OR (public_slug IS NOT NULL AND description IS NOT NULL AND cover_storage_key IS NOT NULL AND published_at IS NOT NULL))", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE UNIQUE INDEX ux_portfolio_games_public_slug ON portfolio_games (lower(public_slug)) WHERE public_slug IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_games_public ON portfolio_games (completed_at DESC) WHERE is_public = true;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_author ON portfolio_game_reviews (author_player_id);", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_public ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION lock_portfolio_publication_mutation() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_portfolio_games_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation BEFORE DELETE OR UPDATE OF scheduled_at ON sessions FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON game_groups FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON players FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("IF final_scheduled_at >= now() THEN IF current_setting('transaction_isolation') <> 'read committed' THEN RAISE EXCEPTION 'portfolio future reschedule requires read committed isolation' USING ERRCODE = '0A000'; END IF; PERFORM pg.id", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("PERFORM pg.id FROM portfolio_games pg WHERE EXISTS", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg; PERFORM pg_advisory_xact_lock(20260530, 108); UPDATE portfolio_games pg SET is_public = false", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("UPDATE portfolio_games pg SET is_public = false, updated_at = now() WHERE pg.is_public = true AND EXISTS (SELECT 1 FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id WHERE pgs.portfolio_game_id = pg.id AND s.scheduled_at >= now());", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("pg_advisory_xact_lock", normalizedMigration[unpublishFunctionStart..unpublishFunctionEnd], StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");
|
||||
|
||||
Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("s3_bucket", migration, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeSql(string sql)
|
||||
{
|
||||
return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace("( ", "(", StringComparison.Ordinal)
|
||||
.Replace(" )", ")", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioPagesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PortfolioEditorPage_ShouldBeAuthorizedAndExposeEditorActions()
|
||||
{
|
||||
var editor = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PortfolioEditor.razor");
|
||||
|
||||
Assert.Contains("@page \"/portfolio/manage/{PortfolioGameId:guid}\"", editor, StringComparison.Ordinal);
|
||||
Assert.Contains("@attribute [Authorize]", editor, StringComparison.Ordinal);
|
||||
Assert.Contains("InputFile", editor, StringComparison.Ordinal);
|
||||
Assert.Contains("ReplaceCoverForCurrentUserAsync", editor, StringComparison.Ordinal);
|
||||
Assert.Contains("SetPublicationForCurrentUserAsync", editor, StringComparison.Ordinal);
|
||||
Assert.Contains("ModerateReviewForCurrentUserAsync", editor, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupDetailsPage_ShouldExposePortfolioManagementActions()
|
||||
{
|
||||
var groupDetails = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
|
||||
Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupCompletedSessionsPage_ShouldBeAuthorizedAndExposeCompletedSessions()
|
||||
{
|
||||
var completedSessions = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor");
|
||||
|
||||
Assert.Contains("@page \"/group/{GroupId:guid}/completed\"", completedSessions, StringComparison.Ordinal);
|
||||
Assert.Contains("@attribute [Authorize]", completedSessions, StringComparison.Ordinal);
|
||||
Assert.Contains("GetCompletedSessionsForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
|
||||
Assert.Contains("CreateDraftForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionHistoryPage_ShouldExposeQuickPortfolioActionForPastSessions()
|
||||
{
|
||||
var sessionHistory = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/SessionHistory.razor");
|
||||
|
||||
Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal);
|
||||
Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppCss_ShouldStylePortfolioManagementComponents()
|
||||
{
|
||||
var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css");
|
||||
|
||||
Assert.Contains(".portfolio-management-list", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-editor-grid", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-option-list", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-review-moderation", css, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicPortfolioPage_ShouldExposeSanitizedDetailAndReviewForm()
|
||||
{
|
||||
var publicPortfolio = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicPortfolio.razor");
|
||||
|
||||
Assert.Contains("@page \"/portfolio/{Slug}\"", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicPortfolioGameBySlugAsync", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.Contains("SubmitReviewForCurrentUserAsync", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.Contains("publicationConsent", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicMasterProfilePage_ShouldIncludePortfolioCardGrid()
|
||||
{
|
||||
var publicMaster = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor");
|
||||
|
||||
Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicClubPage_ShouldIncludePortfolioCardGrid()
|
||||
{
|
||||
var publicClub = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
|
||||
Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppCss_ShouldStylePublicPortfolioComponents()
|
||||
{
|
||||
var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css");
|
||||
|
||||
Assert.Contains(".portfolio-grid", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-card", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-card-cover", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-cover-hero", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-review-list", css, StringComparison.Ordinal);
|
||||
Assert.Contains(".portfolio-review-card", css, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioSchemaGateSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Compose_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy()
|
||||
{
|
||||
var compose = await ReadRepositoryFileAsync("compose.yaml");
|
||||
|
||||
AssertServiceDependsOnHealthyBot(compose, "discord");
|
||||
AssertServiceDependsOnHealthyBot(compose, "web");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aspire_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy()
|
||||
{
|
||||
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
|
||||
|
||||
Assert.Contains(
|
||||
"var bot = builder.AddProject<Projects.GmRelay_Bot>(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\", isProxied: false) .WithHttpHealthCheck(\"/health\", endpointName: \"health\");",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"builder.AddProject<Projects.GmRelay_DiscordBot>(\"discord\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"builder.AddProject<Projects.GmRelay_Web>(\"web\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aspire_ShouldUseApplicationDatabaseConnectionStringName()
|
||||
{
|
||||
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
|
||||
|
||||
Assert.Contains(".AddDatabase(\"gmrelaydb\");", appHost, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName)
|
||||
{
|
||||
var serviceBlock = GetServiceBlock(compose, serviceName);
|
||||
|
||||
Assert.Contains(
|
||||
"""
|
||||
bot:
|
||||
condition: service_healthy
|
||||
""",
|
||||
serviceBlock,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string GetServiceBlock(string compose, string serviceName)
|
||||
{
|
||||
var lines = compose.Split('\n');
|
||||
var start = Array.FindIndex(lines, line => line.TrimEnd('\r') == $" {serviceName}:");
|
||||
Assert.True(start >= 0, $"compose.yaml should contain service '{serviceName}'.");
|
||||
|
||||
var end = Array.FindIndex(
|
||||
lines,
|
||||
start + 1,
|
||||
line => line.StartsWith(" ", StringComparison.Ordinal)
|
||||
&& !line.StartsWith(" ", StringComparison.Ordinal)
|
||||
&& line.TrimEnd('\r').EndsWith(':'));
|
||||
|
||||
return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]);
|
||||
}
|
||||
|
||||
private static string NormalizeSource(string source)
|
||||
{
|
||||
return string.Join(' ', source.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioServiceSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PortfolioService_ShouldExposePortfolioTablesAndPublicationGuards()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs");
|
||||
|
||||
Assert.Contains("portfolio_games", source, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal);
|
||||
Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal);
|
||||
Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal);
|
||||
Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal);
|
||||
Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicMasterPortfolioQuery_ShouldNotRequirePublicSchedule()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs");
|
||||
var publicMasterQuery = PublicMasterQuerySection(source);
|
||||
|
||||
Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicClubPortfolioQuery_ShouldRequirePublicSchedule()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs");
|
||||
var publicClubQuery = PublicClubQuerySection(source);
|
||||
|
||||
Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShowcaseSessionQuery_ShouldKeepFourHourFutureWindow()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
var showcaseQuery = ShowcaseQuerySection(source);
|
||||
|
||||
Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string PublicMasterQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicPortfolioGamesForMasterAsync", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf("GetPublicPortfolioGamesForClubAsync", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..end];
|
||||
}
|
||||
|
||||
private static string PublicClubQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicPortfolioGamesForClubAsync", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf("GetPublicPortfolioGameBySlugAsync", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..end];
|
||||
}
|
||||
|
||||
private static string ShowcaseQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetShowcaseSessionsAsync", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
return string.Empty;
|
||||
|
||||
var end = source.IndexOf("GetShowcaseSessionAsync", start, StringComparison.Ordinal);
|
||||
return end < 0 ? source[start..] : source[start..end];
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioSessionDeletionSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SharedDeleteSessionHandler_ShouldLockSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession()
|
||||
{
|
||||
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
||||
"src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs"));
|
||||
|
||||
const string sessionLock =
|
||||
"FROM sessions s WHERE s.id = @SessionId FOR UPDATE OF s";
|
||||
const string advisoryLock =
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)";
|
||||
const string unpublish =
|
||||
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = @SessionId AND pg.is_public = true";
|
||||
|
||||
Assert.Contains(advisoryLock, source, StringComparison.Ordinal);
|
||||
Assert.Contains(sessionLock, source, StringComparison.Ordinal);
|
||||
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
source.IndexOf(advisoryLock, StringComparison.Ordinal) <
|
||||
source.IndexOf(sessionLock, StringComparison.Ordinal),
|
||||
"The shared delete path must acquire the portfolio advisory lock before locking the session.");
|
||||
Assert.True(
|
||||
source.IndexOf(sessionLock, StringComparison.Ordinal) <
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal),
|
||||
"The shared delete path must lock the session before locking a linked portfolio card.");
|
||||
Assert.True(
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
||||
source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal),
|
||||
"Linked public portfolio cards must be unpublished before deleting the session.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession()
|
||||
{
|
||||
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
||||
"src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs"));
|
||||
|
||||
const string sessionLock =
|
||||
"SELECT s.id FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId FOR UPDATE OF s";
|
||||
const string advisoryLock =
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)";
|
||||
const string unpublish =
|
||||
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true";
|
||||
|
||||
Assert.Contains(advisoryLock, source, StringComparison.Ordinal);
|
||||
Assert.Contains(sessionLock, source, StringComparison.Ordinal);
|
||||
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
||||
Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
source.IndexOf(advisoryLock, StringComparison.Ordinal) <
|
||||
source.IndexOf(sessionLock, StringComparison.Ordinal),
|
||||
"The Discord delete path must acquire the portfolio advisory lock before locking the guild-scoped session.");
|
||||
Assert.True(
|
||||
source.IndexOf(sessionLock, StringComparison.Ordinal) <
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal),
|
||||
"The Discord delete path must lock the guild-scoped session before locking a linked portfolio card.");
|
||||
Assert.True(
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
||||
source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal),
|
||||
"Discord cards must be unpublished before deleting the session.");
|
||||
}
|
||||
|
||||
private static string NormalizeSql(string sql)
|
||||
{
|
||||
return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace("( ", "(", StringComparison.Ordinal)
|
||||
.Replace(" )", ")", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using GmRelay.Web.Services.Portfolio;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(" Dragon Heist ", "dragon-heist")]
|
||||
[InlineData("dragon_heist", "dragon-heist")]
|
||||
[InlineData("Dragon Heist", "dragon-heist")]
|
||||
[InlineData("dragon---heist", "dragon-heist")]
|
||||
[InlineData("dragon-heist-", "dragon-heist")]
|
||||
[InlineData("DRAGON-Heist", "dragon-heist")]
|
||||
public void NormalizeSlug_ShouldReturnCanonicalSlug(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, PortfolioValidation.NormalizeSlug(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("ab")]
|
||||
[InlineData("spaces are fine after normalization but this slug is intentionally far too long to be accepted because it exceeds the maximum portfolio slug size of one hundred and sixty characters")]
|
||||
[InlineData("кириллица")]
|
||||
[InlineData("hello world!")]
|
||||
[InlineData("---")]
|
||||
public void NormalizeSlug_ShouldRejectInvalidSlug(string input)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeSlug(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void NormalizeReviewBody_ShouldRejectBlankText(string? body)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeReviewBody(body));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("This is a perfectly valid review body for the portfolio entry that should pass.")]
|
||||
[InlineData(" Another valid review body that meets the minimum length requirement. ")]
|
||||
public void NormalizeReviewBody_ShouldTrimAndAcceptValidText(string body)
|
||||
{
|
||||
Assert.Equal(body.Trim(), PortfolioValidation.NormalizeReviewBody(body));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" Hello World ")]
|
||||
[InlineData("abc")]
|
||||
public void NormalizeTitle_ShouldTrimAndAcceptValidText(string title)
|
||||
{
|
||||
Assert.Equal(title.Trim(), PortfolioValidation.NormalizeTitle(title));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("a")]
|
||||
public void NormalizeTitle_ShouldRejectTooShort(string title)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeTitle(title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeDescription_ShouldReturnNullForWhitespace()
|
||||
{
|
||||
Assert.Null(PortfolioValidation.NormalizeDescription(null));
|
||||
Assert.Null(PortfolioValidation.NormalizeDescription(""));
|
||||
Assert.Null(PortfolioValidation.NormalizeDescription(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeDescription_ShouldTrimAndAcceptValidText()
|
||||
{
|
||||
Assert.Equal("hello", PortfolioValidation.NormalizeDescription(" hello "));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Online")]
|
||||
[InlineData("Offline")]
|
||||
[InlineData("Hybrid")]
|
||||
public void NormalizeFormat_ShouldAcceptKnownValues(string format)
|
||||
{
|
||||
Assert.Equal(format, PortfolioValidation.NormalizeFormat(format));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void NormalizeFormat_ShouldReturnNullForBlank(string? format)
|
||||
{
|
||||
Assert.Null(PortfolioValidation.NormalizeFormat(format));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("online")]
|
||||
[InlineData("VTT")]
|
||||
public void NormalizeFormat_ShouldRejectUnknownValues(string format)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeFormat(format));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,15 @@
|
||||
"resolved": "5.6.7",
|
||||
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
|
||||
},
|
||||
"Testcontainers.PostgreSql": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.12.0, )",
|
||||
"resolved": "4.12.0",
|
||||
"contentHash": "LZcQu4vfcYuzzy2ENOb7giFb6WNztEEJbufsm7kGiQxjallVuzkWxrBL8LwnjlXGW939pgEZFstL5cO0R2XrBQ==",
|
||||
"dependencies": {
|
||||
"Testcontainers": "4.12.0"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
@@ -70,6 +79,11 @@
|
||||
"Npgsql": "8.0.3"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Dapper": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.72",
|
||||
@@ -94,6 +108,63 @@
|
||||
"dbup-core": "6.1.1"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "tm2V/DpnaURbBhMQ7Z3orNR3u+H863KQuYfA/sgGjI5py07dEeV0I02f6pGrx2869KG9uNM/E96puf9i0gId2w==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0",
|
||||
"Docker.DotNet.Enhanced.LegacyHttp": "4.2.0",
|
||||
"Docker.DotNet.Enhanced.NPipe": "4.2.0",
|
||||
"Docker.DotNet.Enhanced.NativeHttp": "4.2.0",
|
||||
"Docker.DotNet.Enhanced.Unix": "4.2.0"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "cQNxpdadEPdNdfjFCl9vgoCQIK3aVHRn1Qlu36aZUFpp4xHfPrk4hRPNVLR/CpobIFJ+dAt8AceTKMlCfPSccw=="
|
||||
},
|
||||
"Docker.DotNet.Enhanced.LegacyHttp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "sfbMX1HBPUec3PEMoqlP5ak6skXclcTBmu4gG3aUJatP34J2DgvYMP13bvz/rfrjVkAhPqnIiDKiHAkBCokajg==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.NativeHttp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "/ll+2ePYm1qrsMdgMO5BzCQnbfTGmPJAc9SqXEManbliVBZvEpBKHXLugx/OeEca2oC/b4RV+UNPtue5u4jAuA==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.NPipe": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "8wyYOD6VkvqRkITwsvkt3UbW/1WDl6NFypNAsIIDaMiglNRzFrQcK0nK9VUEZa6Oja8Bso3UYySDoL8qatatAA==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.Unix": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "x0wNcbww1+p9nUfw8i+JvsSArBDGkoZ9GI2PZ1wPo85B2OiFrdzp89omounNhO2GKyaIRWAqAm5jYZyNg9EnxA==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.X509": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.2.0",
|
||||
"contentHash": "nMw+FHGwGZieDi7kBgpIVl+E8MzjzXeXHvMQpidLADT06fts2Gw6G+K+p0hMGv7liZULxyYiZnQ1UbE2B9NNQg==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
@@ -341,11 +412,35 @@
|
||||
"Polly.Core": "8.4.2"
|
||||
}
|
||||
},
|
||||
"SharpZipLib": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.4.2",
|
||||
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2"
|
||||
}
|
||||
},
|
||||
"Telegram.Bot": {
|
||||
"type": "Transitive",
|
||||
"resolved": "22.9.6.1",
|
||||
"contentHash": "I0eaMaETcWIhMn4uu4RGd9e6PLJOjaOG3QAcKPsTcS80H3TF6gqj3UF9NKu4ZY90ul6Y6NiWToHkg/PsvxkotA=="
|
||||
},
|
||||
"Testcontainers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.12.0",
|
||||
"contentHash": "PTZRdG1ZVkFMsFbc3cK/VUaOB5L3l4wYL+OkWAK33/cvgd/5FcmZlQ6NhMAl3PWBqYkpdWmeYmQW9U2OIXqtFA==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced": "4.2.0",
|
||||
"Docker.DotNet.Enhanced.X509": "4.2.0",
|
||||
"SSH.NET": "2025.1.0",
|
||||
"SharpZipLib": "1.4.2"
|
||||
}
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
@@ -392,8 +487,8 @@
|
||||
"Aspire.Npgsql": "[13.2.2, )",
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Dapper.AOT": "[1.0.48, )",
|
||||
"GmRelay.ServiceDefaults": "[3.0.9, )",
|
||||
"GmRelay.Shared": "[3.0.9, )",
|
||||
"GmRelay.ServiceDefaults": "[3.5.1, )",
|
||||
"GmRelay.Shared": "[3.5.1, )",
|
||||
"Npgsql": "[10.0.2, )",
|
||||
"Telegram.Bot": "[22.9.5.3, )",
|
||||
"dbup-postgresql": "[7.0.1, )"
|
||||
@@ -405,8 +500,8 @@
|
||||
"Aspire.Npgsql": "[13.2.2, )",
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Dapper.AOT": "[1.0.48, )",
|
||||
"GmRelay.ServiceDefaults": "[3.0.9, )",
|
||||
"GmRelay.Shared": "[3.0.9, )",
|
||||
"GmRelay.ServiceDefaults": "[3.5.1, )",
|
||||
"GmRelay.Shared": "[3.5.1, )",
|
||||
"NetCord.Hosting": "[1.0.0-alpha.489, )",
|
||||
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
|
||||
"NetCord.Services": "[1.0.0-alpha.489, )",
|
||||
@@ -437,8 +532,8 @@
|
||||
"dependencies": {
|
||||
"Aspire.Npgsql": "[13.2.2, )",
|
||||
"Dapper": "[2.1.72, )",
|
||||
"GmRelay.ServiceDefaults": "[3.0.9, )",
|
||||
"GmRelay.Shared": "[3.0.9, )",
|
||||
"GmRelay.ServiceDefaults": "[3.5.1, )",
|
||||
"GmRelay.Shared": "[3.5.1, )",
|
||||
"Npgsql": "[10.0.2, )",
|
||||
"Telegram.Bot": "[22.9.6.1, )"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user