Compare commits

..

36 Commits

Author SHA1 Message Date
Toutsu 6951c72f3c Merge pull request #119: feat(web): private club showcases with membership flow (v3.7.0, issue #110)
Deploy Telegram Bot / build-and-push (push) Successful in 5m29s
Deploy Telegram Bot / scan-images (push) Successful in 1m29s
Deploy Telegram Bot / deploy (push) Successful in 39s
2026-06-03 11:46:01 +03:00
Toutsu 22e9859fdf fix(web): allow cancelling pending applications; drop contradictory message guard
PR Checks / test-and-build (pull_request) Successful in 7m50s
Address review feedback from PR #119:

- LeaveClubMembershipAsync: was rejecting Pending rows because the SQL
  required status = 'Active', so clicking "Отозвать заявку" on a Pending
  membership surfaced a misleading "Active membership X not found"
  InvalidOperationException. Now the method first tries Active -> Left
  and falls back to Pending -> Rejected so the same UI flow covers both
  states.
- PublicClub.razor TrySubmitApplicationAsync: removed the empty-input
  guard that contradicted the "(необязательно)" label and the server
  side (AuthorizedMembershipService already trims and accepts null).

No tests broken (493 still passing), no public-API changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:33:28 +03:00
Toutsu 6cb2fbe610 feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions
exclusively to a club's private showcase, gated behind a
member application and approval flow. Adds a 4-state
publication_mode (None/Catalog/ClubOnly/Both) replacing the
binary is_public, plus a club_memberships table with
Pending/Active/Rejected/Left lifecycle and partial unique
index ensuring a single Active row per (group, player).

Highlights
- V030 migration: club_memberships, publication_mode, drop
  is_public, recreate partial indexes, portfolio_games gains
  publication_mode.
- PublicationMode enum + extensions in GmRelay.Shared.
- ISessionStore gains 12 membership/showcase methods;
  AuthorizedMembershipService owns the membership flow with
  GM-only approve/reject authorization.
- PublicClub / PublicMasterProfile / PublicSession: member-
  aware queries (ClubOnly visible only to Active members).
- New pages: MyClubMemberships (/profile/memberships) and
  ClubApplications (/group/{id}/applications).
- GroupDetails and EditSession switch from a bool toggle to
  a 4-state publication_mode selector.
- NavMenu adds Moji kluby, PublicLayout adds Kluby.

Tests: 4 new test files (PublicationMode, ClubMemberships,
AuthorizedMembershipService, ClubShowcaseSource) + updates
to PublicClubPages, AuthorizedSessionService/Portfolio
service FakeSessionStore, CampaignTemplatesNavigation.
493 tests pass.

Bump version 3.6.0 -> 3.7.0 across Directory.Build.props,
compose.yaml, deploy.yml, NavMenu.razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:09:22 +03:00
Toutsu 992f71c0e4 Merge pull request 'feat(web): add completed-game portfolio to GM showcase (issue #108)' (#118) from codex/feature-issue-108-portfolio into main
Deploy Telegram Bot / build-and-push (push) Successful in 5m36s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 39s
Reviewed-on: #118
2026-06-02 18:28:48 +03:00
Toutsu 21e29564f6 docs: document portfolio release and bump version to 3.6.0
PR Checks / test-and-build (pull_request) Successful in 8m32s
2026-06-02 16:07:01 +03:00
Claude 401653a4d1 feat(web): publish completed game portfolios 2026-06-02 15:41:43 +03:00
Toutsu e970e94e00 feat(web): add portfolio management UI 2026-06-02 15:21:51 +03:00
Claude 242ff99a83 feat(web): authorize portfolio management and reviews 2026-06-02 15:01:29 +03:00
Toutsu f2c9f34ab4 feat(web): add portfolio persistence 2026-06-02 14:46:57 +03:00
Toutsu e5945288ac feat(web): add local portfolio cover storage 2026-06-02 12:35:00 +03:00
Toutsu 7d1489445e feat(web): define portfolio contracts and validation 2026-06-02 12:21:55 +03:00
Toutsu 4af4e52778 docs: sync portfolio task 1 review index 2026-06-02 10:32:34 +03:00
Toutsu a20da4b1a0 fix(data): serialize portfolio mutations before rows 2026-06-02 10:32:13 +03:00
Toutsu edf40c9a09 docs: sync portfolio task 1 review index 2026-06-02 07:57:46 +03:00
Toutsu 1a8161027c fix(data): reject stale reschedule snapshots 2026-06-02 07:57:30 +03:00
Toutsu 85918c1e5d docs: sync portfolio task 1 review index 2026-06-02 07:31:54 +03:00
Toutsu ea714480d3 fix(data): serialize new-link publication races 2026-06-02 07:31:35 +03:00
Toutsu 1d62f69ff0 fix(data): lock racing portfolio publications 2026-06-02 07:10:37 +03:00
Toutsu d762ecc377 fix(data): serialize portfolio future reschedules 2026-06-01 20:58:53 +03:00
Toutsu a28b75dd5b fix(data): align portfolio mutation lock order 2026-06-01 20:23:43 +03:00
Toutsu 2b725708ef test(discord): keep Moscow time parsing fixture in future 2026-06-01 20:00:59 +03:00
Toutsu da0a306340 fix(data): enforce completed portfolio sessions 2026-06-01 15:04:20 +03:00
Toutsu f493836b77 fix(data): reject stale portfolio trigger snapshots 2026-06-01 14:39:04 +03:00
Toutsu 6e7a0cb493 fix(data): enforce portfolio validation isolation 2026-06-01 14:28:51 +03:00
Toutsu 76b3ff7ddf fix(data): serialize portfolio publication validation 2026-06-01 14:12:29 +03:00
Toutsu 536061f63c docs: sync portfolio task 1 review indexes 2026-06-01 10:04:44 +03:00
Toutsu f7a12d14d2 docs: document portfolio concurrency hardening plan 2026-06-01 09:56:33 +03:00
Toutsu 3c1a98bcc4 fix(data): harden portfolio publication concurrency 2026-06-01 09:46:18 +03:00
Toutsu d591e5ed5a fix(data): protect portfolio publication invariant 2026-06-01 09:20:27 +03:00
Toutsu 5809a470b9 test(data): scope portfolio migration assertions 2026-06-01 09:07:47 +03:00
Toutsu ed842d2195 test(data): harden portfolio migration contract 2026-05-30 23:37:40 +03:00
Toutsu a0040ec9fb test(data): tighten portfolio moderation schema assertion 2026-05-30 23:25:12 +03:00
Toutsu 67b8aafd97 feat(data): add completed game portfolio schema 2026-05-30 23:21:31 +03:00
Toutsu ac417731d6 docs: plan completed game portfolio implementation 2026-05-30 21:36:05 +03:00
Toutsu 991c7e1965 docs: specify completed game portfolio 2026-05-30 14:16:12 +03:00
Toutsu 0d9df29f58 Merge pull request #116: feat(web): redesign profile page to match design system
Deploy Telegram Bot / build-and-push (push) Successful in 6m5s
Deploy Telegram Bot / scan-images (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-29 15:19:45 +03:00
67 changed files with 10775 additions and 140 deletions
+3
View File
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.5.1
VERSION: 3.7.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.5.1</Version>
<Version>3.7.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+30 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.5.1`.
**Текущая версия:** `v3.6.0`.
---
@@ -39,6 +39,9 @@
- **🤝 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` у всей пачки;
@@ -126,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
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.0
restart: always
depends_on:
db:
@@ -67,11 +67,13 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.1
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.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.5.1
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.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:
+65 -7
View File
@@ -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, sessions, and GM profiles 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/session/GM profile 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, sanitized master_profiles")
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, session, and GM profile 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/session/GM profile 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, master_profiles, 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 and sanitized GM profile 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
@@ -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.
+9 -5
View File
@@ -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,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';
@@ -0,0 +1,66 @@
-- V030: Private club showcases. Adds club_memberships (member access control)
-- and replaces sessions.is_public with a 4-state publication_mode enum.
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
-- 1. club_memberships
CREATE TABLE club_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
role VARCHAR(20) NOT NULL DEFAULT 'Member'
CHECK (role IN ('Member')),
message TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ,
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
);
-- Only one Active row per (group, player).
-- Re-application after Rejected/Left creates a new row.
CREATE UNIQUE INDEX ux_club_memberships_one_active
ON club_memberships (group_id, player_id)
WHERE status = 'Active';
CREATE INDEX ix_club_memberships_group_status
ON club_memberships (group_id, status);
CREATE INDEX ix_club_memberships_player_status
ON club_memberships (player_id, status);
-- 2. sessions.publication_mode (replaces is_public)
ALTER TABLE sessions
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
-- Backfill before constraint so existing data maps cleanly.
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
ALTER TABLE sessions
ADD CONSTRAINT ck_sessions_publication_mode
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
ALTER TABLE sessions DROP COLUMN is_public;
DROP INDEX IF EXISTS ix_sessions_public_schedule;
DROP INDEX IF EXISTS ix_sessions_showcase;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
-- 3. portfolio_games.publication_mode
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
ALTER TABLE portfolio_games
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
CREATE INDEX ix_portfolio_games_showcase
ON portfolio_games (created_at DESC)
WHERE publication_mode IN ('Catalog', 'Both');
@@ -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,44 @@
namespace GmRelay.Shared.Domain;
public enum PublicationMode
{
None,
Catalog,
ClubOnly,
Both
}
public static class PublicationModeExtensions
{
public const string NoneValue = nameof(PublicationMode.None);
public const string CatalogValue = nameof(PublicationMode.Catalog);
public const string ClubOnlyValue = nameof(PublicationMode.ClubOnly);
public const string BothValue = nameof(PublicationMode.Both);
public static bool IsVisibleInCatalog(this PublicationMode mode) =>
mode is PublicationMode.Catalog or PublicationMode.Both;
public static bool IsVisibleToClubMembers(this PublicationMode mode) =>
mode is PublicationMode.ClubOnly or PublicationMode.Both;
public static string ToDatabaseValue(this PublicationMode mode) =>
mode switch
{
PublicationMode.None => NoneValue,
PublicationMode.Catalog => CatalogValue,
PublicationMode.ClubOnly => ClubOnlyValue,
PublicationMode.Both => BothValue,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown publication mode.")
};
public static PublicationMode FromDatabaseValue(string? value) =>
value switch
{
null or "" => PublicationMode.None,
NoneValue => PublicationMode.None,
CatalogValue => PublicationMode.Catalog,
ClubOnlyValue => PublicationMode.ClubOnly,
BothValue => PublicationMode.Both,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown publication mode.")
};
}
@@ -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
@@ -18,5 +18,7 @@ public sealed record ShowcaseSessionDto(
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description,
string PublicationMode = "None",
bool IsMembersOnly = false,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
@@ -41,6 +41,15 @@
</svg>
Профиль
</NavLink>
<NavLink class="nav-item" href="profile/memberships" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l8-4v18"/>
<path d="M19 21V11l-6-4"/>
</svg>
Мои клубы
</NavLink>
</div>
<div class="nav-footer">
@@ -73,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.5.1</div>
<div class="nav-version">v3.7.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -6,7 +6,10 @@
<img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span>
</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
<div class="public-topbar-actions">
<a class="btn-gm btn-gm-outline" href="/showcase">Клубы</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
</div>
</header>
<main class="public-content">
@@ -0,0 +1,151 @@
@page "/group/{GroupId:guid}/applications"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject AuthorizedMembershipService MembershipService
@inject AuthorizedSessionService SessionService
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using System.Security.Claims
<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>Одобряйте или отклоняйте заявки на участие в клубе.</p>
</div>
@if (accessDenied)
{
<div class="glass-card public-empty-state">
<h2>Нет доступа</h2>
<p>Только owner или co-GM группы могут просматривать заявки.</p>
</div>
}
else if (applications is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 70%; height: 2rem; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 90%;"></div>
</div>
}
else if (applications.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Новых заявок нет</h2>
<p>Когда игроки подадут заявку на участие в клубе, она появится здесь.</p>
</div>
}
else
{
<ul class="application-list">
@foreach (var app in applications)
{
<li class="glass-card application-item">
<div class="application-info">
<strong>@app.DisplayName</strong>
<span class="status-badge status-neutral">@app.Platform</span>
@if (!string.IsNullOrWhiteSpace(app.ExternalUsername))
{
<span class="application-meta">@app.ExternalUsername</span>
}
<span class="application-meta">@app.AppliedAt.ToString("dd.MM.yyyy HH:mm")</span>
@if (!string.IsNullOrWhiteSpace(app.Message))
{
<p class="application-message">«@app.Message»</p>
}
</div>
<div class="application-actions">
<button type="button" class="btn-gm btn-gm-success" disabled="@(busyMembershipId is not null)" @onclick="() => Approve(app.MembershipId)">
✅ Одобрить
</button>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(busyMembershipId is not null)" @onclick="() => Reject(app.MembershipId)">
❌ Отклонить
</button>
</div>
</li>
}
</ul>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
}
</div>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebPendingApplication>? applications;
private bool accessDenied;
private string? errorMessage;
private Guid? busyMembershipId;
protected override async Task OnParametersSetAsync()
{
accessDenied = false;
try
{
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
}
catch (SessionAccessDeniedException)
{
accessDenied = true;
}
}
private async Task Approve(Guid membershipId)
{
errorMessage = null;
busyMembershipId = membershipId;
try
{
await MembershipService.ApproveForCurrentGmAsync(membershipId);
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
}
catch (SessionAccessDeniedException)
{
accessDenied = true;
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
finally
{
busyMembershipId = null;
}
}
private async Task Reject(Guid membershipId)
{
errorMessage = null;
busyMembershipId = membershipId;
try
{
await MembershipService.RejectForCurrentGmAsync(membershipId);
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
}
catch (SessionAccessDeniedException)
{
accessDenied = true;
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
finally
{
busyMembershipId = null;
}
}
}
@@ -57,6 +57,16 @@
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Режим публикации</label>
<InputSelect @bind-Value="model.PublicationMode" class="gm-form-control">
<option value="@PublicationModeExtensions.NoneValue">Скрыта</option>
<option value="@PublicationModeExtensions.CatalogValue">В каталоге</option>
<option value="@PublicationModeExtensions.ClubOnlyValue">Только для участников клуба</option>
<option value="@PublicationModeExtensions.BothValue">Каталог + клуб</option>
</InputSelect>
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
@@ -104,6 +114,7 @@
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers;
model.PublicationMode = session.PublicationMode;
}
private async Task HandleSubmit()
@@ -123,6 +134,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
@@ -147,5 +159,6 @@
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
public string JoinLink { get; set; } = "";
public int? MaxPlayers { get; set; }
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
}
}
@@ -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,13 @@
@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 AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -124,6 +127,14 @@
</div>
}
@if (pendingApplicationsCount > 0)
{
<a class="glass-card applications-card" href="@($"/group/{GroupId}/applications")">
<span class="status-badge status-warning">📨 Заявки участников (@pendingApplicationsCount)</span>
<span>Рассмотреть заявки на участие в клубе</span>
</a>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -138,6 +149,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">
@@ -257,11 +322,12 @@
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
@(IsBatchPublishBusy(batch)
? "Обновляем..."
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
</button>
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
</select>
</div>
<div class="batch-clone-row">
@@ -313,11 +379,12 @@
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
@@ -410,11 +477,12 @@
</div>
</div>
<div class="session-card-actions">
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
<select class="gm-form-control" style="flex: 1; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
@@ -481,8 +549,10 @@
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private WebPublicGroupSettings? publicSettings;
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private int pendingApplicationsCount;
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
@@ -490,6 +560,7 @@
private Guid? publishingSessionId;
private string? removingCoGmId;
private bool isAddingCoGm;
private bool isCreatingDraft;
private bool savingPublicSettings;
private string? currentPlatform;
private string? externalUserId;
@@ -545,11 +616,40 @@
return;
}
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(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;
@@ -579,7 +679,7 @@
}
}
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
@@ -587,10 +687,14 @@
try
{
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
successMessage = isPublic
? "Batch опубликован в публичном расписании."
: "Batch скрыт из публичного расписания.";
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
_ => "Batch скрыт из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -607,7 +711,7 @@
}
}
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
@@ -615,10 +719,14 @@
try
{
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
successMessage = isPublic
? "Сессия опубликована в публичном расписании."
: "Сессия скрыта из публичного расписания.";
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
_ => "Сессия скрыта из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -988,7 +1096,13 @@
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
AllSessionsPublic = orderedSessions.All(session => session.IsPublic),
PublicationMode = orderedSessions
.Select(s => PublicationModeExtensions.FromDatabaseValue(s.PublicationMode))
.GroupBy(m => m)
.OrderByDescending(g => g.Count())
.First()
.Key
};
})
.OrderBy(batch => batch.FirstScheduledAtLocal)
@@ -1135,6 +1249,9 @@
: seats;
}
private static PublicationMode ParseMode(object? value) =>
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
@@ -1187,6 +1304,7 @@
public int SessionCount { get; init; }
public int PublicSessionCount { get; init; }
public bool AllSessionsPublic { get; init; }
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
public string CloneInterval { get; set; } = "week";
}
@@ -0,0 +1,147 @@
@page "/profile/memberships"
@using GmRelay.Web.Services
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject AuthorizedMembershipService MembershipService
@inject NavigationManager Navigation
<PageTitle>Мои клубы — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Мои клубы</li>
</ul>
<div class="page-header animate-fade-in">
<h2>🏛 Мои клубы</h2>
<p>Заявки и активные участия в приватных клубных витринах.</p>
</div>
@if (memberships is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 80%;"></div>
</div>
}
else if (memberships.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Вы пока не подавали заявок</h2>
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
</div>
}
else
{
@if (activeMemberships.Count > 0)
{
<section class="glass-card animate-slide-up">
<h3>Активные участия</h3>
<ul class="membership-list">
@foreach (var membership in activeMemberships)
{
<li>
<div class="membership-info">
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
@membership.GroupName
</a>
<span class="status-badge status-success">Участник</span>
</div>
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
Покинуть клуб
</button>
</li>
}
</ul>
</section>
}
@if (pendingMemberships.Count > 0)
{
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
<h3>Заявки на рассмотрении</h3>
<ul class="membership-list">
@foreach (var membership in pendingMemberships)
{
<li>
<div class="membership-info">
<span class="membership-name">@membership.GroupName</span>
<span class="status-badge status-warning">Ожидает одобрения</span>
</div>
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
Отозвать заявку
</button>
</li>
}
</ul>
</section>
}
@if (historyMemberships.Count > 0)
{
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
<h3>История</h3>
<ul class="membership-list">
@foreach (var membership in historyMemberships)
{
<li>
<div class="membership-info">
<span class="membership-name">@membership.GroupName</span>
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
</span>
@if (membership.DecidedAt is not null)
{
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
}
</div>
</li>
}
</ul>
</section>
}
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
}
</div>
@code {
private List<WebMembership>? memberships;
private List<WebMembership> activeMemberships = [];
private List<WebMembership> pendingMemberships = [];
private List<WebMembership> historyMemberships = [];
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
errorMessage = null;
memberships = await MembershipService.GetMineAsync();
activeMemberships = memberships.Where(m => m.Status == "Active").ToList();
pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList();
historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList();
}
private async Task Leave(Guid membershipId)
{
errorMessage = null;
try
{
await MembershipService.LeaveClubForCurrentUserAsync(membershipId);
await LoadAsync();
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
}
}
@@ -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();
}
}
+149 -16
View File
@@ -1,7 +1,13 @@
@page "/club/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@inject AuthorizedMembershipService MembershipService
@using System.Security.Claims
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
<PageTitle>@PageTitleText</PageTitle>
@@ -58,22 +64,88 @@ else if (club is not null)
}
else
{
<div class="public-session-list">
@foreach (var session in club.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
@if (publicSessions.Count > 0)
{
<div class="public-session-list">
@foreach (var session in publicSessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
@if (membersOnlySessions.Count > 0)
{
<section class="glass-card members-only-section">
<h2>Игры для участников клуба</h2>
@if (viewerIsActiveMember)
{
<div class="public-session-list">
@foreach (var session in membersOnlySessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge status-warning">Только для участников</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
else
{
<p>Эти сессии доступны только одобренным участникам клуба.</p>
@if (viewerPlayerId is null)
{
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
}
else
{
<details class="application-form">
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
<div class="gm-form-group">
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
</div>
@if (!string.IsNullOrEmpty(applicationError))
{
<p class="form-error">@applicationError</p>
}
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
</EditForm>
</details>
}
}
</section>
}
}
@if (portfolioGames.Count > 0)
{
<section class="glass-card portfolio-section">
<h2>Завершённые игры клуба</h2>
<p>Публичные портфолио, опубликованные мастерами этого клуба.</p>
<PortfolioCardGrid Games="portfolioGames" />
</section>
}
}
@@ -81,7 +153,35 @@ else if (club is not null)
[Parameter] public string? Slug { get; set; }
private WebPublicClub? club;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
private bool loaded;
private Guid? viewerPlayerId;
private bool viewerIsActiveMember;
private string? applicationError;
private string? applicationMessage;
private bool isSubmittingApplication;
private async Task TrySubmitApplicationAsync()
{
applicationError = null;
if (club is null)
return;
try
{
isSubmittingApplication = true;
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
applicationMessage = null;
}
catch (InvalidOperationException ex)
{
applicationError = ex.Message;
}
finally
{
isSubmittingApplication = false;
}
}
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
@@ -93,9 +193,42 @@ 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();
applicationError = null;
applicationMessage = null;
// Resolve viewer identity (player id) for member-aware access.
var user = HttpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
{
// We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly
// by reading both claims. Simpler: only resolve when both Platform and externalUserId are present.
var platform = user.FindFirst("Platform")?.Value;
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
: null;
}
else
{
viewerPlayerId = null;
}
club = trimmedSlug is null
? null
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
if (club is not null && viewerPlayerId is not null)
{
viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value);
}
else
{
viewerIsActiveMember = false;
}
loaded = true;
}
@@ -1,7 +1,11 @@
@page "/gm/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
<PageTitle>@PageTitleText</PageTitle>
@@ -83,12 +87,22 @@ else if (profile 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 GmRelay.Web.Services.PublicMasterProfile? profile;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
private bool loaded;
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
@@ -101,9 +115,24 @@ else if (profile is not null)
protected override async Task OnParametersSetAsync()
{
loaded = false;
profile = string.IsNullOrWhiteSpace(Slug)
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
Guid? viewerPlayerId = null;
var user = HttpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
{
var platform = user.FindFirst("Platform")?.Value;
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
: null;
}
profile = trimmedSlug is null
? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
loaded = true;
}
@@ -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; }
}
}
@@ -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,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
};
}
+2 -1
View File
@@ -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
+8
View File
@@ -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,16 @@ 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<AuthorizedMembershipService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
builder.Services.AddScoped<AuthorizedPortfolioService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -94,6 +100,8 @@ app.Use(async (context, next) =>
await next();
});
app.UsePortfolioCoverFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
@@ -0,0 +1,124 @@
using System.Security.Claims;
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedMembershipService(ISessionStore sessionStore, 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 ?? externalUserId;
return (platform, externalUserId, name);
}
public async Task<Guid> ApplyForCurrentUserAsync(Guid groupId, string? message)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
{
throw new InvalidOperationException("Player record not found for current user.");
}
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
if (normalizedMessage?.Length > 1000)
{
throw new InvalidOperationException("Сообщение заявки должно быть не длиннее 1000 символов.");
}
return await sessionStore.ApplyForMembershipAsync(groupId, playerId.Value, normalizedMessage);
}
public async Task<List<WebMembership>> GetMineAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return [];
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
return [];
return await sessionStore.GetMembershipsForPlayerAsync(playerId.Value);
}
public async Task LeaveClubForCurrentUserAsync(Guid membershipId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
throw new InvalidOperationException("Player record not found for current user.");
await sessionStore.LeaveClubMembershipAsync(membershipId, playerId.Value);
}
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
}
return await sessionStore.GetPendingApplicationsAsync(groupId);
}
public async Task<int> GetPendingApplicationsCountForCurrentGmAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return 0;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return 0;
return await sessionStore.GetPendingApplicationsCountAsync(groupId);
}
public async Task ApproveForCurrentGmAsync(Guid membershipId)
{
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
await sessionStore.ApproveMembershipAsync(membershipId, approverPlayerId);
}
public async Task RejectForCurrentGmAsync(Guid membershipId)
{
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
await sessionStore.RejectMembershipAsync(membershipId, approverPlayerId);
}
private async Task<(Guid ApproverPlayerId, Guid GroupId)> ResolveMembershipContextForGmAsync(Guid membershipId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
throw new InvalidOperationException("Player record not found for current user.");
var groupId = await sessionStore.GetGroupIdForMembershipAsync(membershipId);
if (groupId is null)
throw new InvalidOperationException($"Membership {membershipId} not found.");
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId.Value, identity.Value.ExternalUserId);
}
return (playerId.Value, groupId.Value);
}
}
@@ -136,7 +136,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
normalizedBio);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
{
var identity = GetCurrentIdentity();
if (identity is null)
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
await sessionStore.SetSessionPublicationModeAsync(sessionId, session.GroupId, mode);
}
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
public async Task SetBatchPublicationModeForCurrentUserAsync(Guid batchId, PublicationMode mode)
{
var identity = GetCurrentIdentity();
if (identity is null)
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
await sessionStore.SetBatchPublicationModeAsync(batchId, batch.GroupId, mode);
}
public async Task<bool> IsActiveClubMemberForCurrentUserAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return false;
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
return false;
return await sessionStore.IsActiveClubMemberAsync(groupId, playerId.Value);
}
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
+59 -5
View File
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string PublicationMode = PublicationModeExtensions.NoneValue,
bool IsMembersOnly = false,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record WebMembership(
Guid MembershipId,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Status,
string Role,
string? Message,
DateTime AppliedAt,
DateTime? DecidedAt,
string? DecidedByDisplayName);
public sealed record WebPendingApplication(
Guid MembershipId,
Guid PlayerId,
string DisplayName,
string Platform,
string? ExternalUsername,
string? Message,
DateTime AppliedAt);
public sealed record WebClubShowcaseSession(
Guid Id,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string PublicationMode,
bool IsMembersOnly,
string? Description,
bool AllowDirectRegistration);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
@@ -79,12 +120,14 @@ public interface ISessionStore
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -110,7 +153,7 @@ public interface ISessionStore
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);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
@@ -123,6 +166,17 @@ public interface ISessionStore
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);
// --- Private club showcases / memberships (issue #110) ---
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
}
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');
}
+343 -32
View File
@@ -69,7 +69,19 @@ public sealed record WebSession(
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null,
bool IsPublic = false);
string PublicationMode = PublicationModeExtensions.NoneValue)
{
public bool IsPublic
{
get
{
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
}
}
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
}
public sealed record WebParticipant(
Guid Id,
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
bool AllowDirectRegistration,
string? Description,
string? MasterProfileSlug,
string? MasterDisplayName);
string? MasterDisplayName,
string PublicationMode = "None",
bool IsMembersOnly = false);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
public sealed class SessionService(
@@ -233,7 +247,7 @@ public sealed class SessionService(
SELECT COUNT(*) AS count
FROM sessions s
WHERE s.group_id = g.id
AND s.is_public = true
AND s.publication_mode IN ('Catalog', 'Both')
) public_counts ON true
WHERE g.id = @GroupId
""",
@@ -266,18 +280,18 @@ public sealed class SessionService(
}
}
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
{
await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET is_public = @IsPublic,
SET publication_mode = @Mode,
updated_at = now()
WHERE id = @SessionId
AND group_id = @GroupId
""",
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
if (updatedRows == 0)
{
@@ -285,18 +299,18 @@ public sealed class SessionService(
}
}
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
{
await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET is_public = @IsPublic,
SET publication_mode = @Mode,
updated_at = now()
WHERE batch_id = @BatchId
AND group_id = @GroupId
""",
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
if (updatedRows == 0)
{
@@ -304,7 +318,7 @@ public sealed class SessionService(
}
}
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
@@ -345,11 +359,11 @@ public sealed class SessionService(
return null;
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
}
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
@@ -364,6 +378,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
@@ -404,9 +420,21 @@ public sealed class SessionService(
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
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
""",
new
{
@@ -414,7 +442,8 @@ public sealed class SessionService(
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
ViewerPlayerId = viewerPlayerId
});
}
@@ -479,7 +508,7 @@ public sealed class SessionService(
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.publication_mode IN ('Catalog', 'Both')
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
@@ -518,7 +547,10 @@ public sealed class SessionService(
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();
r.Description,
PublicationMode: "Catalog",
IsMembersOnly: false,
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
}
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
@@ -583,7 +615,7 @@ public sealed class SessionService(
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.publication_mode IN ('Catalog', 'Both')
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
""",
@@ -603,7 +635,10 @@ public sealed class SessionService(
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);
row.Description,
PublicationMode: "Catalog",
IsMembersOnly: false,
row.MasterProfileSlug, row.MasterDisplayName);
}
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
@@ -617,7 +652,7 @@ public sealed class SessionService(
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.is_public = true
AND s.publication_mode IN ('Catalog', 'Both')
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.scheduled_at > now() - interval '4 hours'
@@ -868,7 +903,7 @@ public sealed class SessionService(
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -907,7 +942,7 @@ public sealed class SessionService(
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -967,7 +1002,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -1054,7 +1089,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1181,7 +1216,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1951,7 +1986,7 @@ public sealed class SessionService(
}
}
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
@@ -1971,7 +2006,7 @@ public sealed class SessionService(
return null;
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
}
@@ -2004,7 +2039,8 @@ public sealed class SessionService(
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
NpgsqlConnection conn,
Guid playerId)
Guid playerId,
Guid? viewerPlayerId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
@@ -2018,6 +2054,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
@@ -2051,9 +2089,21 @@ public sealed class SessionService(
) waitlist_counts ON true
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
ORDER BY s.scheduled_at
""",
new
@@ -2061,13 +2111,15 @@ public sealed class SessionService(
PlayerId = playerId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
ViewerPlayerId = viewerPlayerId
})).ToList();
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
NpgsqlConnection conn,
Guid groupId)
Guid groupId,
Guid? viewerPlayerId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
@@ -2081,6 +2133,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
@@ -2121,9 +2175,21 @@ public sealed class SessionService(
WHERE s.group_id = @GroupId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
ORDER BY s.scheduled_at
""",
new
@@ -2132,7 +2198,8 @@ public sealed class SessionService(
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
ViewerPlayerId = viewerPlayerId
})).ToList();
}
@@ -2432,4 +2499,248 @@ public sealed class SessionService(
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
transaction);
}
// --- Private club showcases / memberships (issue #110) ---
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var count = await conn.ExecuteScalarAsync<long>(
"""
SELECT COUNT(*) FROM club_memberships
WHERE group_id = @GroupId
AND player_id = @PlayerId
AND status = 'Active'
""",
new { GroupId = groupId, PlayerId = playerId });
return count > 0;
}
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
}
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebClubShowcaseSession>(
"""
SELECT s.id AS Id,
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.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
s.description AS Description,
s.allow_direct_registration AS AllowDirectRegistration
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Active
) active_counts ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
WHERE s.group_id = @GroupId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
ORDER BY s.scheduled_at ASC
OFFSET @Offset LIMIT @PageSize
""",
new
{
GroupId = groupId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
ViewerPlayerId = viewerPlayerId,
Offset = page * pageSize,
PageSize = pageSize
})).ToList();
}
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)::int FROM club_memberships
WHERE group_id = @GroupId AND status = 'Pending'
""",
new { GroupId = groupId });
}
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebPendingApplication>(
"""
SELECT cm.id AS MembershipId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.platform AS Platform,
p.external_username AS ExternalUsername,
cm.message AS Message,
cm.applied_at AS AppliedAt
FROM club_memberships cm
JOIN players p ON p.id = cm.player_id
WHERE cm.group_id = @GroupId
AND cm.status = 'Pending'
ORDER BY cm.applied_at ASC
""",
new { GroupId = groupId })).ToList();
}
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebMembership>(
"""
SELECT cm.id AS MembershipId,
cm.group_id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
g.public_slug AS GroupSlug,
cm.status AS Status,
cm.role AS Role,
cm.message AS Message,
cm.applied_at AS AppliedAt,
cm.decided_at AS DecidedAt,
decider.display_name AS DecidedByDisplayName
FROM club_memberships cm
JOIN game_groups g ON g.id = cm.group_id
LEFT JOIN players decider ON decider.id = cm.decided_by
WHERE cm.player_id = @PlayerId
ORDER BY cm.applied_at DESC
""",
new { PlayerId = playerId })).ToList();
}
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
{
await using var conn = await dataSource.OpenConnectionAsync();
var existing = await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)::int FROM club_memberships
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
""",
new { GroupId = groupId, PlayerId = playerId });
if (existing > 0)
{
throw new InvalidOperationException("Active or pending application already exists for this player.");
}
return await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO club_memberships (group_id, player_id, status, message)
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
RETURNING id
""",
new { GroupId = groupId, PlayerId = playerId, Message = message });
}
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
WHERE id = @MembershipId AND status = 'Pending'
""",
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
if (rows == 0)
{
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
}
}
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
WHERE id = @MembershipId AND status = 'Pending'
""",
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
if (rows == 0)
{
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
}
}
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
// Active membership: withdraw by setting status = 'Left'.
var rows = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Left', decided_at = now()
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
""",
new { MembershipId = membershipId, PlayerId = playerId });
if (rows > 0)
{
return;
}
// Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
var cancelled = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Rejected', decided_at = now()
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
""",
new { MembershipId = membershipId, PlayerId = playerId });
if (cancelled == 0)
{
throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
}
}
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT group_id FROM club_memberships WHERE id = @MembershipId
""",
new { MembershipId = membershipId });
}
}
@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"PortfolioCovers": {
"StoragePath": "../../artifacts/portfolio-covers"
}
}
+424
View File
@@ -2021,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);
@@ -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,62 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedMembershipServiceTests
{
[Fact]
public async Task AuthorizedMembershipService_ShouldRequireAuthenticationForApply()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("ApplyForCurrentUserAsync", service, StringComparison.Ordinal);
Assert.Contains("User is not authenticated", service, StringComparison.Ordinal);
Assert.Contains("GetPlayerIdByPlatformIdentityAsync", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldValidateMessageLength()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("1000", service, StringComparison.Ordinal);
Assert.Contains("н", service, StringComparison.Ordinal); // Russian message length
}
[Fact]
public async Task AuthorizedMembershipService_ShouldRestrictGmActionsToGroupManagers()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("ResolveMembershipContextForGmAsync", service, StringComparison.Ordinal);
Assert.Contains("GetGroupIdForMembershipAsync", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldExposePendingApplications()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("GetPendingApplicationsAsync", service, StringComparison.Ordinal);
Assert.Contains("GetPendingApplicationsCountForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("GetMineAsync", service, StringComparison.Ordinal);
Assert.Contains("LeaveClubForCurrentUserAsync", service, 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,868 @@
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 SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException();
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => 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, Guid? viewerPlayerId = null) => 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;
}
}
@@ -839,9 +839,11 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastPublicSessionId { get; private set; }
public Guid? LastPublicSessionGroupId { get; private set; }
public bool? LastSessionPublicValue { get; private set; }
public PublicationMode? LastSessionPublicationMode { get; private set; }
public Guid? LastPublicBatchId { get; private set; }
public Guid? LastPublicBatchGroupId { get; private set; }
public bool? LastBatchPublicValue { get; private set; }
public PublicationMode? LastBatchPublicationMode { get; private set; }
public bool RemovePlayerCalled { get; private set; }
public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; }
@@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
{
SetSessionPublicCalled = true;
LastPublicSessionId = sessionId;
LastPublicSessionGroupId = groupId;
LastSessionPublicValue = isPublic;
LastSessionPublicationMode = mode;
if (sessionsById.TryGetValue(sessionId, out var session))
{
sessionsById[sessionId] = session with { IsPublic = isPublic };
sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() };
}
return Task.CompletedTask;
}
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
{
SetBatchPublicCalled = true;
LastPublicBatchId = batchId;
LastPublicBatchGroupId = groupId;
LastBatchPublicValue = isPublic;
LastBatchPublicationMode = mode;
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
{
sessionsById[session.Id] = session with { IsPublic = isPublic };
sessionsById[session.Id] = session with { PublicationMode = mode.ToDatabaseValue() };
}
return Task.CompletedTask;
}
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicClub?>(null);
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicSession?>(null);
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) =>
Task.FromResult(false);
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(null);
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) =>
Task.FromResult<IReadOnlyList<WebClubShowcaseSession>>([]);
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) =>
Task.FromResult(0);
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) =>
Task.FromResult(new List<WebPendingApplication>());
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) =>
Task.FromResult(new List<WebMembership>());
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) =>
throw new NotImplementedException();
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
throw new NotImplementedException();
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
throw new NotImplementedException();
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) =>
throw new NotImplementedException();
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) =>
Task.FromResult<Guid?>(null);
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<PublicMasterProfile?>(null);
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, 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.7.0", navMenu, StringComparison.Ordinal);
}
[Fact]
@@ -0,0 +1,94 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class ClubMembershipsTests
{
[Fact]
public async Task SessionStore_ShouldExposeMembershipMethods()
{
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
Assert.Contains("ApplyForMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("ApproveMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("RejectMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("LeaveClubMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPendingApplicationsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetMembershipsForPlayerAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("IsActiveClubMemberAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetGroupIdForMembershipAsync", sessionStore, StringComparison.Ordinal);
}
[Fact]
public async Task SessionService_ShouldFilterPublicSessionsWithMemberAwareClause()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
// Member-aware: ClubOnly only visible to Active members
Assert.Contains("publication_mode = 'ClubOnly'", service, StringComparison.Ordinal);
Assert.Contains("club_memberships", service, StringComparison.Ordinal);
Assert.Contains("cm.status = 'Active'", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldValidateCallerForGmActions()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("IsGroupManagerAsync", service, StringComparison.Ordinal);
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("SessionAccessDeniedException", service, StringComparison.Ordinal);
}
[Fact]
public async Task MyClubMembershipsPage_ShouldRenderLeaveAndCancelButtons()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/MyClubMemberships.razor");
Assert.Contains("@page \"/profile/memberships\"", page, StringComparison.Ordinal);
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
Assert.Contains("Покинуть клуб", page, StringComparison.Ordinal);
Assert.Contains("Отозвать заявку", page, StringComparison.Ordinal);
Assert.Contains("Active", page, StringComparison.Ordinal);
Assert.Contains("Pending", page, StringComparison.Ordinal);
}
[Fact]
public async Task ClubApplicationsPage_ShouldRenderApproveAndReject()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/ClubApplications.razor");
Assert.Contains("/applications", page, StringComparison.Ordinal);
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
Assert.Contains("Одобрить", page, StringComparison.Ordinal);
Assert.Contains("Отклонить", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldExposeApplicationCtaAndMembersOnlyBlock()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("viewerPlayerId", page, StringComparison.Ordinal);
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
Assert.Contains("ApplyForCurrentUserAsync", page, 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,104 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class ClubShowcaseSourceTests
{
[Fact]
public async Task PublicClubPage_ShouldRenderMembersOnlyBlock()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
Assert.Contains("members-only-section", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldRenderApplyAndLoginCtas()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
Assert.Contains("applicationMessage", page, StringComparison.Ordinal);
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldHideMembersOnlyBlockForAnonymous()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
// Anonymous users must not see the members-only block content
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
// Login CTA appears when viewerPlayerId is null
Assert.Contains("viewerPlayerId is null", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicLayout_ShouldExposeClubsLink()
{
var layout = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/PublicLayout.razor");
Assert.Contains("href=\"/showcase\"", layout, StringComparison.Ordinal);
Assert.Contains("Клубы", layout, StringComparison.Ordinal);
}
[Fact]
public async Task NavMenu_ShouldExposeMyClubsLink()
{
var menu = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/NavMenu.razor");
Assert.Contains("href=\"profile/memberships\"", menu, StringComparison.Ordinal);
Assert.Contains("Мои клубы", menu, StringComparison.Ordinal);
}
[Fact]
public async Task GroupDetails_ShouldExposeApplicationsLink()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
Assert.Contains("/applications", page, StringComparison.Ordinal);
Assert.Contains("Заявки участников", page, StringComparison.Ordinal);
Assert.Contains("pendingApplicationsCount", page, StringComparison.Ordinal);
}
[Fact]
public async Task GroupDetails_ShouldUsePublicationModeSelectorNotBooleanToggle()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
Assert.DoesNotContain("SetSessionPublic(session.Id, !session.IsPublic)", page, StringComparison.Ordinal);
Assert.DoesNotContain("SetBatchPublic(batch, !batch.AllSessionsPublic)", page, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationMode", page, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationMode", page, StringComparison.Ordinal);
}
[Fact]
public async Task EditSession_ShouldExposePublicationModeSelector()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/EditSession.razor");
Assert.Contains("PublicationMode", page, StringComparison.Ordinal);
Assert.Contains("Режим публикации", page, StringComparison.Ordinal);
Assert.Contains("Catalog", page, StringComparison.Ordinal);
Assert.Contains("ClubOnly", page, StringComparison.Ordinal);
Assert.Contains("Both", page, 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,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,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));
}
}
@@ -14,6 +14,20 @@ public sealed class PublicClubPagesTests
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldAddClubMembershipsAndPublicationMode()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
Assert.Contains("status", migration, StringComparison.Ordinal);
Assert.Contains("role", migration, StringComparison.Ordinal);
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
Assert.Contains("publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
}
[Fact]
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
{
@@ -40,10 +54,10 @@ public sealed class PublicClubPagesTests
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationModeAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationModeAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
Assert.Contains("s.publication_mode IN ('Catalog', 'Both')", service, StringComparison.Ordinal);
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
}
@@ -55,8 +69,8 @@ public sealed class PublicClubPagesTests
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
@@ -0,0 +1,79 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class PublicationModeTests
{
[Fact]
public void PublicationMode_ShouldHaveFourValues()
{
var values = Enum.GetValues<GmRelay.Shared.Domain.PublicationMode>();
Assert.Equal(4, values.Length);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.None, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Catalog, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.ClubOnly, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Both, values);
}
[Fact]
public async Task MigrationV030_ShouldAddClubMembershipsTable()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
Assert.Contains("status", migration, StringComparison.Ordinal);
Assert.Contains("role", migration, StringComparison.Ordinal);
Assert.Contains("Pending", migration, StringComparison.Ordinal);
Assert.Contains("Active", migration, StringComparison.Ordinal);
Assert.Contains("Rejected", migration, StringComparison.Ordinal);
Assert.Contains("Left", migration, StringComparison.Ordinal);
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
Assert.Contains("Member", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldReplaceIsPublicWithPublicationModeEnum()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("ADD COLUMN publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("ck_sessions_publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("'None', 'Catalog', 'ClubOnly', 'Both'", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE sessions SET publication_mode = 'None' WHERE is_public = false", migration, StringComparison.Ordinal);
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldRecreatePartialIndexUsingPublicationMode()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
Assert.Contains("publication_mode IN ('Catalog', 'Both')", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldAddPortfolioPublicationModeColumn()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
Assert.Contains("ix_portfolio_games_showcase", migration, 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}'.");
}
}
+101 -6
View File
@@ -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, )"
}