Compare commits

...

58 Commits

Author SHA1 Message Date
Toutsu 71080aeab6 @chore(release): bump version to 3.8.0 (issue #111)
Deploy Telegram Bot / build-and-push (push) Successful in 5m3s
Deploy Telegram Bot / scan-images (push) Successful in 1m40s
Deploy Telegram Bot / deploy (push) Successful in 37s
Game-creation wizard: replace /newsession text template with step-by-step
inline-keyboard flow for single games and game pools.

- Directory.Build.props: 3.7.1 -> 3.8.0
- compose.yaml: pin bot, discord-bot and web images to 3.8.0
- deploy.yml VERSION env: 3.7.1 -> 3.8.0
- NavMenu.razor: nav-version 3.7.1 -> 3.8.0

🤖 Generated with Claude Code
@
2026-06-04 15:49:01 +03:00
Toutsu a843c8b278 style: dotnet format pass on wizard code
PR Checks / test-and-build (pull_request) Successful in 8m12s
Deploy Telegram Bot / build-and-push (push) Successful in 5m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m34s
Deploy Telegram Bot / deploy (push) Successful in 42s
2026-06-04 15:33:25 +03:00
Toutsu 186492a18d test(wizard): add submit, cleanup, router delegation tests 2026-06-04 10:33:50 +03:00
Toutsu 2819786f91 test(wizard): add wizard tests + refactor to IWizardDraftRepository
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot
  mock sealed classes; the codebase uses fake-style doubles instead).
- Add step-transition, pool-slot, validation, cancel/back, and render-shape tests
  using FakeWizardDraftRepository and FakeWizardMessenger.
- Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync
  now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent
  LoadPayload calls see the user's progress. Previously, local WizardPayload
  mutations were discarded and the wizard reset on every step.
- CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when
  one is missing, so the PoolSlotCapacity → waitlist click is recoverable
  even if the user lands on the step without a slot.
2026-06-04 09:53:15 +03:00
Toutsu 8c1bda73ed feat(wizard): register wizard services in Program.cs DI 2026-06-04 09:18:16 +03:00
Toutsu af345ba765 feat(wizard): delegate updates to wizard when an active draft exists 2026-06-04 09:14:13 +03:00
Toutsu 4a04d7d723 refactor(wizard): make CreateSessionHandler wizard-driven and remove legacy parser 2026-06-04 09:00:37 +03:00
Toutsu eeffae659f feat(wizard): add WizardDraftCleanupService (1-min tick) 2026-06-04 08:44:57 +03:00
Toutsu ea567a36ee feat(wizard): add GameCreationWizard state-machine service 2026-06-04 08:42:43 +03:00
Toutsu be86a2a08a feat(wizard): add WizardStep renderer (single + pool steps) 2026-06-04 08:33:53 +03:00
Toutsu 1b49211085 feat(wizard): add WizardStorageException 2026-06-04 08:30:50 +03:00
Toutsu 96a4807002 feat(wizard): add ITelegramWizardMessenger (edit/send/answer/club-list) 2026-06-04 08:28:30 +03:00
Toutsu cff4e48b57 feat(wizard): add step name and callback data constants 2026-06-04 08:18:15 +03:00
Toutsu 384887a862 test(wizard): add WizardDraftRepository integration tests 2026-06-04 08:13:22 +03:00
Toutsu 4d2aef637f fix(wizard): bind @PayloadJson parameter in UpsertAsync INSERT
The UpsertAsync SQL used @Payload (without 'Json' suffix) but the
WizardDraft POCO exposes the property as PayloadJson. Dapper.AOT
requires parameter names to match property names, so the parameter
went through unbinded and PostgreSQL rejected 'payload' as a column
reference. Without integration tests this went unnoticed; the new
WizardDraftRepositoryTests now exercise the path and surface it.
2026-06-04 08:13:10 +03:00
Toutsu c45c46abcf feat(wizard): add WizardDraftRepository (Dapper.AOT) 2026-06-04 08:01:46 +03:00
Toutsu 2c7495cd8d feat(wizard): add WizardPayload with AOT JSON source-gen 2026-06-04 07:59:13 +03:00
Toutsu d5fdc19016 feat(wizard): add WizardDraft POCO 2026-06-04 07:56:38 +03:00
Toutsu 10410d758c feat(db): add wizard_drafts table (V031) 2026-06-04 07:54:43 +03:00
Toutsu 771ff9be34 Merge pull request #120: fix(web): include PublicationMode/IsMembersOnly in showcase SQL (v3.7.1)
Deploy Telegram Bot / build-and-push (push) Successful in 5m11s
Deploy Telegram Bot / scan-images (push) Successful in 1m30s
Deploy Telegram Bot / deploy (push) Successful in 38s
2026-06-03 22:31:17 +03:00
Toutsu 29f6f6a827 fix(web): include PublicationMode/IsMembersOnly in showcase SQL to fix /showcase 500
PR Checks / test-and-build (pull_request) Successful in 8m17s
Dapper.AOT generated a 19-parameter ctor for ShowcaseSessionRow based on the
SELECT list in GetShowcaseSessionsAsync / GetShowcaseSessionAsync. After
adding PublicationMode and IsMembersOnly to ShowcaseSessionDto in v3.7.0 the
record itself was extended, but the SELECT still returned 19 columns, so the
materializer threw "A parameterless default constructor or one matching
signature (...) is required" and every request to /showcase returned 500.

Add s.publication_mode and (s.publication_mode = 'ClubOnly') to both SELECT
lists and propagate them through the ShowcaseSessionDto construction. The
field list now matches the generated constructor exactly.

Version bump 3.7.0 -> 3.7.1 (patch).
2026-06-03 22:21:31 +03:00
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
Toutsu d54950698a feat(web): redesign profile page to match design system
PR Checks / test-and-build (pull_request) Successful in 12m8s
- Rewrite Profile.razor to use .page-container, .glass-card, .gm-alert,

  .btn-gm, .status-badge, .empty-state and other standard design system classes

- Replace custom unstyled markup with breadcrumb, page-header, skeleton loaders

- Add .identity-list styles to app.css for linked accounts section

- Unify visual language with Home, Templates and GroupDetails pages
2026-05-29 15:15:48 +03:00
104 changed files with 14334 additions and 719 deletions
+3
View File
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД # Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups BACKUP_VOLUME_NAME=game_pgbackups
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.5.1 VERSION: 3.8.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.5.1</Version> <Version>3.8.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+30 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру 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**. - **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются. - **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
- **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers. - **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/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 из шаблона. - **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**: - **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки; - обновить общий `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. 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`; игроки записываются и выходят кнопками в опубликованном сообщении. 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 и восстановление
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose. Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
+11 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.1 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,11 +67,13 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.1 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
bot:
condition: service_healthy
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "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}" - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
@@ -84,11 +86,13 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.1 image: git.codeanddice.ru/toutsu/gmrelay-web:3.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
bot:
condition: service_healthy
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "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}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
@@ -97,10 +101,12 @@ services:
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}" - "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}" - "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}" - "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
ports: ports:
- "${GMRELAY_WEB_PORT:-8080}:8080" - "${GMRELAY_WEB_PORT:-8080}:8080"
volumes: volumes:
- web_keys:/app/dataprotection-keys - web_keys:/app/dataprotection-keys
- portfolio_covers:/app/portfolio-covers
networks: networks:
- gmrelay - gmrelay
healthcheck: healthcheck:
@@ -116,6 +122,8 @@ volumes:
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys} name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
pgbackups: pgbackups:
name: ${BACKUP_VOLUME_NAME:-game_pgbackups} name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
networks: networks:
gmrelay: gmrelay:
+65 -7
View File
@@ -8,19 +8,20 @@ C4Context
Person(gm, "Game Master", "Creates sessions and manages schedules") Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders") 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(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") 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, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions") Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons") Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP 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(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions") Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery") Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
@@ -41,19 +42,21 @@ C4Container
System_Boundary(runtime, "Docker Compose / Aspire runtime") { System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders") 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(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") 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(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST 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, telegram, "Commands")
Rel(gm, discord, "Slash commands") Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries") Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions") 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(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events") Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls") Rel(bot, telegram, "Bot API calls")
@@ -64,6 +67,7 @@ C4Container
Rel(bot, db, "Npgsql + Dapper.AOT") Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper") Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper") Rel(web, db, "Npgsql + Dapper")
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
``` ```
## Level 3: Component - Session Interactions ## Level 3: Component - Session Interactions
@@ -125,3 +129,57 @@ C4Component
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery") Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
Rel(healthCheck, discord, "HTTP /health") 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") var postgres = builder.AddPostgres("postgres")
.WithPgAdmin() .WithPgAdmin()
.AddDatabase("gmrelay-db"); .AddDatabase("gmrelaydb");
builder.AddProject<Projects.GmRelay_Bot>("bot") var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres) .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") builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres); .WaitFor(postgres)
.WaitFor(bot);
builder.AddProject<Projects.GmRelay_Web>("web") builder.AddProject<Projects.GmRelay_Web>("web")
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres); .WaitFor(postgres)
.WaitFor(bot);
builder.Build().Run(); builder.Build().Run();
@@ -1,194 +1,254 @@
using Dapper; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Bot.Infrastructure.Telegram; using Microsoft.Extensions.Logging;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed class CreateSessionHandler( /// <summary>
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler, /// Wizard-driven entry point for game-session creation. Replaces the legacy
NpgsqlDataSource dataSource, /// text-template parser. Exposes <see cref="StartWizardAsync"/> (called from
IPlatformMessenger messenger, /// <c>/newsession</c>), <see cref="TryResumeAsync"/> (continue a draft), and
ILogger<CreateSessionHandler> logger) /// <see cref="SubmitDraftAsync"/> (finalize on "✅ Создать" callback).
/// </summary>
public sealed class CreateSessionHandler
{ {
public async Task HandleAsync(Message message, CancellationToken ct) private const int MaxRetries = 3;
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly ITelegramWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
ITelegramWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
{ {
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow); _drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
}
foreach (var timeInput in parseResult.PastTimeInputs) /// <summary>
/// Entry point for <c>/newsession</c>. If a non-expired draft already exists for
/// this (chat, thread, owner), returns <c>null</c> so the caller can render a
/// "Continue / Start over / Cancel" menu.
/// </summary>
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
{
var existing = await _drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
if (existing is not null)
{ {
await messenger.SendGroupMessageAsync( return null;
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
ct);
} }
foreach (var timeInput in parseResult.InvalidTimeInputs) var draft = new WizardDraft
{ {
await messenger.SendGroupMessageAsync( Id = Guid.NewGuid(),
TelegramPlatformIds.Group(message.Chat.Id, null), ChatId = message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.", MessageThreadId = message.MessageThreadId,
ct); OwnerTelegramId = message.From?.Id ?? 0,
} Step = WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs) var (text, kb) = WizardStep.Render(draft, new WizardPayload());
var msgId = await _messenger.SendGroupMessageAsync(
draft.ChatId, draft.MessageThreadId, text, kb, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
/// <summary>
/// Resume an existing draft — returns the draft row so the caller can re-render.
/// </summary>
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct) =>
_drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
/// <summary>
/// Finalize: build shared command(s), call the shared handler, edit the wizard message.
/// On failure, retry up to <see cref="MaxRetries"/> times before deleting the draft.
/// </summary>
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
{ {
await messenger.SendGroupMessageAsync( await _messenger.EditMessageTextAsync(
TelegramPlatformIds.Group(message.Chat.Id, null), draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.", $"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct);
ct);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
ct);
}
if (!parseResult.IsValid)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"""
Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""",
ct);
return; return;
} }
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl); var commands = BuildCommands(draft, payload);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var messageThreadId = topicDestination.MessageThreadId;
if (topicDestination.ShouldCreateForumTopic)
{
try
{
var topicRef = await messenger.CreateThreadAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"🎲 Игры: {parseResult.Title}",
ct);
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex)
when (ex.Message.Contains("not enough rights") ||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
ex.Message.Contains("not an administrator"))
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
TelegramTopicRouting.MissingForumTopicRightsMessage,
ct);
return;
}
}
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
var platformUser = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
gmName,
gmUsername);
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
platformUser,
platformGroup,
parseResult.Title!,
parseResult.Link!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
imageReference);
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
try try
{ {
result = await sharedHandler.HandleAsync(command, ct); foreach (var cmd in commands)
} {
catch await _shared.HandleAsync(cmd, ct);
{ }
await messenger.SendGroupMessageAsync( var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
TelegramPlatformIds.Group(message.Chat.Id, null), await _messenger.EditMessageTextAsync(
"💥 Произошла ошибка базы данных при создании сессии.", draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
ct); $"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
throw; EmptyKeyboard(), ct);
} await _drafts.DeleteAsync(draft.Id, ct);
if (!result.Success)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
result.ErrorMessage!,
ct);
return;
}
var scheduleMessage = new PlatformScheduleMessage(
platformGroup,
result.View!,
null,
imageReference);
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
// Store batch_message_id
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessageId, BatchId = result.BatchId });
}
// Delete original message
try
{
await messenger.DeleteMessageAsync(
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id); _log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
payload.RetryCount += 1;
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
EmptyKeyboard(), ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelKeyboard(), ct);
} }
} }
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl) // ── Build shared commands ────────────────────────────────────────
// The shared handler creates one session per scheduled time in a single transaction
// and assigns the same batch_id to all of them. A wizard pool therefore produces ONE
// command with N times; a single-game wizard produces ONE command with one time.
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{ {
var attachedPhotoFileId = message.Photo? if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
.OrderByDescending(photo => photo.FileSize ?? 0)
.ThenByDescending(photo => photo.Width * photo.Height)
.FirstOrDefault()
?.FileId;
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
{ {
return attachedPhotoFileId; return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false)
};
} }
return new List<CreateSessionCommand>
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim(); {
BuildCommand(
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
isOneShot: true)
};
} }
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers,
bool isOneShot)
{
var gmId = draft.OwnerTelegramId;
var user = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Telegram,
draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
DisplayName: string.Empty,
ExternalChannelId: null,
ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
return new CreateSessionCommand(
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
}
private static GameSystem? ParseSystem(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
{
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
}
else
{
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
}
missing = string.Join(", ", missingFields);
return missingFields.Count == 0;
}
// ── Payload I/O ──────────────────────────────────────────────────
private static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload p)
{
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
}
// ── Keyboards ────────────────────────────────────────────────────
private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty<InlineKeyboardButton[]>());
private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
} }
@@ -1,184 +0,0 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
string? ImageUrl,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] ImagePrefixes =
[
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
];
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
string? imageUrl = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (imagePrefix is not null)
{
imageUrl = line[imagePrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
{
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
{
maxPlayers = parsedMaxPlayers;
}
else
{
invalidSeatLimitInputs.Add(seatLimitInput);
}
continue;
}
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult(
title,
link,
imageUrl,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs,
invalidRecurringInputs);
}
}
@@ -0,0 +1,504 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Central state machine for the game/pool creation wizard.
/// </summary>
public sealed class GameCreationWizard
{
private readonly IWizardDraftRepository _drafts;
private readonly ITelegramWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
IWizardDraftRepository drafts,
ITelegramWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
_drafts = drafts;
_messenger = messenger;
_log = log;
}
/// <summary>Handle a text or callback update from the owning GM.</summary>
public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct)
{
try
{
if (update.CallbackQuery is { } cb)
{
await HandleCallbackAsync(draft, cb, ct);
}
else if (update.Message is { } msg)
{
await HandleTextAsync(draft, msg, ct);
}
}
catch (WizardStorageException)
{
// Surface storage failure; do not crash the update loop.
if (update.CallbackQuery is { } cb2)
{
await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id);
if (update.CallbackQuery is { } cb3)
{
try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); }
catch { /* swallow — we're already in error path */ }
}
}
}
private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct)
{
if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice))
{
await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct);
return;
}
switch (action)
{
case "cancel":
await _drafts.DeleteAsync(draft.Id, ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
"❌ Мастер отменён.", EmptyKeyboard, ct);
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
return;
case "back":
ApplyBack(draft, step);
await PersistAndRenderAsync(draft, cb.Id, ct);
return;
case "create":
// Routed by CreateSessionHandler, not here.
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
return;
default:
// For "Choice" callbacks, action == step.
await ApplyChoiceAsync(draft, step, choice, cb.Id, ct);
return;
}
}
private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct)
{
if (msg.Text is not { } text)
{
// Photo or other non-text — handle cover step only.
if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover)
{
var fileId = msg.Photo[^1].FileId;
ApplyCoverPhoto(draft, fileId);
await PersistAndRenderAsync(draft, null, ct);
}
return;
}
var (nextStep, error, payload) = ApplyText(draft, text);
if (payload is { } p) SavePayload(draft, p);
if (error is { } errMsg && draft.DraftMessageId is { } mid)
{
// Re-render the same step with ⚠️ prefix.
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, mid,
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
return;
}
if (nextStep is { } step)
{
draft.Step = step;
}
await PersistAndRenderAsync(draft, null, ct);
}
private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct)
{
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
if (error is { } err)
{
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
return;
}
if (payload is { } p) SavePayload(draft, p);
if (nextStep is { } s)
{
draft.Step = s;
}
await PersistAndRenderAsync(draft, callbackId, ct);
}
private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct)
{
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null;
if (draft.Step == WizardStepNames.PickClub)
{
clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct);
}
var (text, kb) = WizardStep.Render(draft, payload, clubs);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
text, kb, ct);
if (callbackId is { } id)
{
await _messenger.AnswerCallbackAsync(id, null, ct);
}
}
// ── Text input dispatcher ─────────────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input)
{
var payload = LoadPayload(draft);
switch (draft.Step)
{
case WizardStepNames.Title:
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
? (WizardStepNames.Description, SetTitle(payload, title), payload)
: (null, title, payload);
case WizardStepNames.Description:
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
: (null, desc, payload);
case WizardStepNames.Cover:
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload);
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
return (null, "Некорректный URL", payload);
case WizardStepNames.System when payload.System is null:
// "Other" branch — only active if free-text was offered.
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
: (null, sys, payload);
case WizardStepNames.Duration when payload.DurationMinutes is null:
return TryParseHours(input, out var durMin)
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.DateTime:
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.PoolSystemDuration when payload.System is null:
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
: (null, psys, payload);
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.PoolSlotCapacity:
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
: (null, "Лимит должен быть 1..50", payload);
default:
return (null, "Ожидается выбор кнопкой", payload);
}
}
// ── Callback (button) dispatcher ──────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice)
{
var payload = LoadPayload(draft);
var (next, err) = step switch
{
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice),
WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice),
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
_ => (null, "Неизвестный шаг"),
};
return (next, err, payload);
}
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
{
"single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)),
"pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.System, null), // stay, await text
"_skip" => (NextAfterSystem(p), SetSystem(p, null)),
{ } code => (WizardStepNames.Duration, SetSystem(p, code)),
};
private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.Duration, null),
"_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)),
{ } d => int.TryParse(d, out var min)
? (NextAfterDuration(p), SetDurationMinutes(p, min))
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
"club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)),
"members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)),
"pickclub" => (WizardStepNames.PickClub, null),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
"yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)),
"no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
{
"add" => BeginNewPoolSlot(p),
"done" => p.Pool?.Slots.Count > 0
? (WizardStepNames.PoolConfirm, null)
: (null, "Добавьте хотя бы один слот"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)),
"waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)),
_ => (null, "Неизвестный выбор"),
};
// ── Back navigation ───────────────────────────────────────────────
private static void ApplyBack(WizardDraft draft, string fromStep)
{
// The callback's "step" portion is the step the user is currently on (e.g. the
// Confirm button emits `wizard:back` with no step, in which case we fall back to
// the draft's current step). Both should produce the same result.
var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep;
var payload = LoadPayload(draft);
var previous = PreviousStep(current, payload);
if (previous is { } step) draft.Step = step;
}
private static string? PreviousStep(string step, WizardPayload p) => step switch
{
WizardStepNames.Title => null, // first step
WizardStepNames.Description => WizardStepNames.Title,
WizardStepNames.Cover => WizardStepNames.Description,
WizardStepNames.System => WizardStepNames.Cover,
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
_ => null,
};
// ── Payload I/O ───────────────────────────────────────────────────
internal static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload payload)
{
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload, WizardPayloadJsonContext.Default.WizardPayload);
}
// Mutators — return the error message if any (kept here to centralise flow).
private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; }
private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; }
private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; }
private static void ApplyCoverPhoto(WizardDraft d, string fileId)
{
var p = LoadPayload(d);
p.ImageFileId = fileId;
p.ImageUrl = null;
SavePayload(d, p);
var next = NextAfterCover(p);
if (next is { } s) d.Step = s;
}
private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; }
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.ScheduledAt = v;
return null;
}
private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.MaxPlayers = v;
return null;
}
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.Waitlist = waitlist;
return null;
}
private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p)
{
p.Pool ??= new WizardPoolInput();
p.Pool.Slots.Add(new WizardSlotInput());
return (WizardStepNames.PoolSlotDateTime, null);
}
private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p)
{
// Slots added via BeginNewPoolSlot are always committed before they leave the
// PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without
// a slot, start a new one to keep the flow recoverable.
p.Pool ??= new WizardPoolInput();
var last = p.Pool.Slots.LastOrDefault();
if (last is not null && last.MaxPlayers == 0) return last;
p.Pool.Slots.Add(new WizardSlotInput());
return p.Pool.Slots[^1];
}
// ── Flow helpers ──────────────────────────────────────────────────
private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool
? WizardStepNames.PoolSystemDuration : WizardStepNames.System;
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p)
{
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members)
{
if (p.ClubId is null) return WizardStepNames.PickClub;
}
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
}
private static (string? sys, int? dur) SplitSystemDuration(string s)
{
var idx = s.IndexOf(':');
if (idx <= 0 || idx >= s.Length - 1) return (null, null);
var sys = s.Substring(0, idx);
if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null);
return (sys, durMin);
}
private static bool ValidateText(
string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed)
{
trimmed = input.Trim();
if (string.IsNullOrEmpty(trimmed))
{
trimmed = emptyMsg;
return false;
}
if (trimmed.Length > maxLength)
{
trimmed = tooLongMsg;
return false;
}
return true;
}
private static bool TryParseHours(string input, out int minutes)
{
minutes = 0;
var s = input.Trim();
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false;
minutes = (int)Math.Round(hours * 60);
return true;
}
private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty<InlineKeyboardButton[]>());
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed record WizardClubOption(Guid ClubId, string Name);
public interface ITelegramWizardMessenger
{
Task<long> EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
Task<long> SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct);
Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct);
}
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class TelegramWizardMessenger(
ITelegramBotClient bot,
NpgsqlDataSource dataSource) : ITelegramWizardMessenger
{
public async Task<long> EditMessageTextAsync(
long chatId, int? messageThreadId, long messageId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
{
var msg = await bot.EditMessageText(
chatId: chatId,
messageId: (int)messageId,
text: text,
replyMarkup: keyboard,
cancellationToken: ct);
return msg.MessageId;
}
public async Task<long> SendGroupMessageAsync(
long chatId, int? messageThreadId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
{
var msg = await bot.SendMessage(
chatId: chatId,
text: text,
messageThreadId: messageThreadId,
replyMarkup: keyboard,
cancellationToken: ct);
return msg.MessageId;
}
public async Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
{
await bot.AnswerCallbackQuery(callbackId, text: text, cancellationToken: ct);
}
public async Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
{
// Adjusted from the plan: this codebase models "clubs" as game_groups
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
const string sql = """
SELECT g.id AS ClubId,
g.name AS Name
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
JOIN players p ON p.id = gm.player_id
WHERE p.platform = 'Telegram'
AND p.external_user_id = @ExternalId
GROUP BY g.id, g.name
ORDER BY g.name
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>(
new CommandDefinition(sql, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct));
return rows.AsList();
}
}
@@ -0,0 +1,25 @@
using System;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public static class WizardCallbackData
{
public const string Prefix = "wizard";
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
public static string Back() => $"{Prefix}:back";
public static string Cancel() => $"{Prefix}:cancel";
public static string Create() => $"{Prefix}:create";
public static bool TryParse(string? data, out string action, out string step, out string choice)
{
action = step = choice = string.Empty;
if (string.IsNullOrEmpty(data)) return false;
var parts = data.Split(':', 3);
if (parts.Length < 2 || parts[0] != Prefix) return false;
action = parts[1];
step = parts.Length >= 3 ? parts[1] : string.Empty;
choice = parts.Length >= 3 ? parts[2] : string.Empty;
return true;
}
}
@@ -0,0 +1,60 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftCleanupService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IWizardDraftRepository _drafts;
private readonly ILogger<WizardDraftCleanupService> _log;
public WizardDraftCleanupService(
IWizardDraftRepository drafts,
ILogger<WizardDraftCleanupService> log)
{
_drafts = drafts;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RunOnceAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
// graceful shutdown
}
}
internal async Task RunOnceAsync(CancellationToken ct)
{
try
{
var deleted = await _drafts.DeleteExpiredAsync(ct);
if (deleted > 0)
{
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard cleanup tick failed");
}
}
}
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.Text;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public static class WizardStep
{
public const int MaxTitleLength = 200;
public const int MaxDescriptionLength = 4000;
public const int MaxSystemLength = 100;
public const int MaxCapacity = 50;
public const int MinCapacity = 1;
public const int MinDurationHours = 1;
public const int MaxDurationHours = 12;
public static (string text, InlineKeyboardMarkup keyboard) Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? clubs = null)
{
return draft.Step switch
{
WizardStepNames.Type => RenderType(),
WizardStepNames.Title => RenderTitle(),
WizardStepNames.Description => RenderDescription(),
WizardStepNames.Cover => RenderCover(),
WizardStepNames.System => RenderSystem(),
WizardStepNames.Duration => RenderDuration(),
WizardStepNames.DateTime => RenderDateTime(),
WizardStepNames.Capacity => RenderCapacity(),
WizardStepNames.Visibility => RenderVisibility(),
WizardStepNames.PickClub => RenderPickClub(clubs ?? Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => RenderPublish(),
WizardStepNames.Confirm => RenderSingleConfirm(payload),
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
};
}
// ── Single-game renderers ──────────────────────────────────────────
private static (string, InlineKeyboardMarkup) RenderType() => (
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) },
new[] { InlineKeyboardButton.WithCallbackData("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool")) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
}));
private static (string, InlineKeyboardMarkup) RenderTitle() => (
"📝 Введите название игры одним сообщением.",
BackCancel());
private static (string, InlineKeyboardMarkup) RenderDescription() => (
"📄 Введите описание (или «-», чтобы пропустить).",
SkipBackCancel());
private static (string, InlineKeyboardMarkup) RenderCover() => (
"🖼 Пришлите картинку как вложение или URL (или «-»).",
SkipBackCancel());
private static (string, InlineKeyboardMarkup) RenderSystem()
{
var buttons = new List<InlineKeyboardButton[]>
{
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) },
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")) },
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")) },
new[] { InlineKeyboardButton.WithCallbackData("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")) },
new[] { InlineKeyboardButton.WithCallbackData("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")) },
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")) },
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")) },
};
return ("🎲 Выберите систему.", new InlineKeyboardMarkup(buttons).AppendBackCancel());
}
private static (string, InlineKeyboardMarkup) RenderDuration() => (
"⏱ Выберите длительность.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")) },
new[] { InlineKeyboardButton.WithCallbackData("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")) },
new[] { InlineKeyboardButton.WithCallbackData("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")) },
new[] { InlineKeyboardButton.WithCallbackData("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")) },
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")) },
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderDateTime() => (
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
BackCancel());
private static (string, InlineKeyboardMarkup) RenderCapacity() => (
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on")) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderVisibility() => (
"🔒 Выберите видимость.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public")) },
new[] { InlineKeyboardButton.WithCallbackData("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club")) },
new[] { InlineKeyboardButton.WithCallbackData("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")) },
new[] { InlineKeyboardButton.WithCallbackData("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
{
if (clubs.Count == 0)
{
return (
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
BackCancel());
}
var rows = new List<InlineKeyboardButton[]>();
foreach (var club in clubs)
{
rows.Add(new[]
{
InlineKeyboardButton.WithCallbackData(club.Name, WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString()))
});
}
return ("🏷 Выберите клуб:", new InlineKeyboardMarkup(rows).AppendBackCancel());
}
private static (string, InlineKeyboardMarkup) RenderPublish() => (
"✨ Опубликовать в витрине сейчас?",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes")) },
new[] { InlineKeyboardButton.WithCallbackData("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderSingleConfirm(WizardPayload p)
{
var sb = new StringBuilder();
sb.AppendLine("👀 Проверьте перед созданием:");
sb.AppendLine();
sb.AppendLine($"🎲 {p.Title}");
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
return (sb.ToString(), new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
}));
}
// ── Pool renderers ─────────────────────────────────────────────────
private static (string, InlineKeyboardMarkup) RenderPoolSystemDuration() => (
"🎲 Выберите систему и длительность пула.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")) },
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")) },
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")) },
new[] { InlineKeyboardButton.WithCallbackData("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")) },
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolAddSlots(WizardPayload p) => (
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")) },
new[] { InlineKeyboardButton.WithCallbackData("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolSlotDateTime() => (
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
BackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolSlotCapacity() => (
"👥 Введите лимит мест (1..50) и выберите waitlist.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on")) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolConfirm(WizardPayload p)
{
var sb = new StringBuilder();
sb.AppendLine("👀 Проверьте пул перед созданием:");
sb.AppendLine();
sb.AppendLine($"📝 {p.Title}");
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
if (p.Pool is not null)
{
foreach (var s in p.Pool.Slots)
{
sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}");
}
}
return (sb.ToString(), new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать пул", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
}));
}
// ── Helpers ────────────────────────────────────────────────────────
private static InlineKeyboardMarkup BackCancel() => new(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private static InlineKeyboardMarkup SkipBackCancel() => new(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")) },
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private static string RenderVisibilityText(WizardVisibility? v) => v switch
{
WizardVisibility.Public => "публичная в общем showcase",
WizardVisibility.Club => "публичная в витрине клуба",
WizardVisibility.Members => "только для членов клуба",
_ => "не задана",
};
}
internal static class InlineKeyboardMarkupExtensions
{
public static InlineKeyboardMarkup AppendBackCancel(this InlineKeyboardMarkup kb) => kb;
}
@@ -0,0 +1,24 @@
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public static class WizardStepNames
{
public const string Type = "Type";
public const string Title = "Title";
public const string Description = "Description";
public const string Cover = "Cover";
public const string System = "System";
public const string Duration = "Duration";
public const string DateTime = "DateTime";
public const string Capacity = "Capacity";
public const string Visibility = "Visibility";
public const string PickClub = "PickClub";
public const string Publish = "Publish";
public const string Confirm = "Confirm";
// Pool steps
public const string PoolSystemDuration = "PoolSystemDuration";
public const string PoolAddSlots = "PoolAddSlots";
public const string PoolSlotDateTime = "PoolSlotDateTime";
public const string PoolSlotCapacity = "PoolSlotCapacity";
public const string PoolConfirm = "PoolConfirm";
}
@@ -0,0 +1,8 @@
using System;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardStorageException : Exception
{
public WizardStorageException(string message, Exception inner) : base(message, inner) { }
}
@@ -2,8 +2,10 @@
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.ListSessions; using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler; using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
@@ -34,12 +36,44 @@ public sealed class UpdateRouter(
InitiateRescheduleHandler initiateRescheduleHandler, InitiateRescheduleHandler initiateRescheduleHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler, BotRescheduleVoteHandler rescheduleVoteHandler,
GameCreationWizard wizard,
IWizardDraftRepository drafts,
ITelegramBotClient bot, ITelegramBotClient bot,
IConfiguration configuration, IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{ {
public async Task RouteAsync(Update update, CancellationToken ct) public async Task RouteAsync(Update update, CancellationToken ct)
{ {
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
// (chat, thread, owner), every update routes to the wizard. The wizard is
// responsible for both text input and callback handling.
if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId))
{
var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct);
if (draft is not null)
{
// Resume / Reset / Cancel menu callbacks live in the router because
// they cross draft boundaries (reset deletes + recreates a fresh
// draft, which the wizard instance doesn't know how to do).
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
{
return;
}
await wizard.HandleUpdateAsync(update, draft, ct);
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
// acknowledges the callback; the actual session creation lives in
// CreateSessionHandler.
if (update.CallbackQuery?.Data is { } data &&
data == WizardCallbackData.Create())
{
await createSessionHandler.SubmitDraftAsync(draft, ct);
}
return;
}
}
switch (update) switch (update)
{ {
case { CallbackQuery: { } query }: case { CallbackQuery: { } query }:
@@ -63,9 +97,106 @@ public sealed class UpdateRouter(
} }
} }
/// <summary>
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
/// if the update was consumed and the wizard should be skipped. The wizard still
/// owns "cancel" and "create".
/// </summary>
private async Task<bool> TryHandleDraftControlCallbackAsync(
Update update, WizardDraft draft, CancellationToken ct)
{
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
return false;
switch (data)
{
case WizardControlCallbacks.Resume:
// Re-render the current step of the existing draft. We answer the
// callback here because the wizard will not be called.
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
await bot.EditMessageText(
chatId: cbMessage.Chat.Id,
messageId: cbMessage.MessageId,
text: text,
replyMarkup: kb,
cancellationToken: ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
return true;
case WizardControlCallbacks.Reset:
// Delete the existing draft and start a fresh one. The wizard is
// bypassed entirely because the active draft is now gone.
await drafts.DeleteAsync(draft.Id, ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
var newDraft = await createSessionHandler.StartWizardAsync(
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
if (newDraft is null)
{
// Race: another wizard just started for the same owner. The
// user can simply re-run /newsession. We don't loop.
await bot.SendMessage(
chatId: cbMessage.Chat.Id,
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
cancellationToken: ct);
}
return true;
}
return false;
}
/// <summary>
/// Build a synthetic <see cref="Message"/> carrying just the fields
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
/// </summary>
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
{
Chat = new Chat { Id = chatId },
MessageThreadId = messageThreadId,
From = new User { Id = fromId },
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
GameCreationWizard.LoadPayload(draft);
internal static string GetCommandText(Message message) internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart(); => (message.Text ?? message.Caption ?? string.Empty).TrimStart();
/// <summary>
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
/// Returns false for updates that carry no usable origin (e.g. inline queries).
/// </summary>
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId)
{
chatId = 0;
messageThreadId = null;
ownerId = 0;
switch (update)
{
case { Message: { From: not null, Chat: { } chat } msg }:
chatId = chat.Id;
messageThreadId = msg.MessageThreadId;
ownerId = msg.From!.Id;
return true;
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
chatId = cbmChat.Id;
messageThreadId = cb.Message?.MessageThreadId;
ownerId = cb.From!.Id;
return true;
case { CallbackQuery: { From: not null } cb2 }:
// Callback arrived without a message (e.g. from a Mini App). No chat
// context → wizard cannot run on this update.
ownerId = cb2.From!.Id;
return false;
default:
return false;
}
}
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct) private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{ {
if (query.Data is not { } data || query.Message is not { } message) if (query.Data is not { } data || query.Message is not { } message)
@@ -213,7 +344,7 @@ public sealed class UpdateRouter(
break; break;
case "/newsession": case "/newsession":
await createSessionHandler.HandleAsync(message, ct); await HandleNewSessionCommandAsync(message, ct);
break; break;
case "/listsessions": case "/listsessions":
@@ -256,6 +387,45 @@ public sealed class UpdateRouter(
} }
} }
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
{
// Try to start a fresh wizard. StartWizardAsync returns null when a
// non-expired draft already exists for this (chat, thread, owner).
var draft = await createSessionHandler.StartWizardAsync(message, ct);
if (draft is not null)
{
// New draft was created and its first step has been rendered.
return;
}
// Existing draft. Look it up so we can describe the current step and offer
// a Continue / Start over / Cancel menu.
var existing = await createSessionHandler.TryResumeAsync(message, ct);
if (existing is null)
{
// Race: the draft expired between the two calls (or the user lacks
// ownership metadata). Fall back to silently starting a new wizard.
await createSessionHandler.StartWizardAsync(message, ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "У вас уже есть незавершённый мастер. Продолжить?",
replyMarkup: ContinueResetCancelKeyboard(),
cancellationToken: ct);
}
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
{
// "Продолжить" re-renders the existing draft's current step (router-level).
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
// "Отмена" delegates to the wizard's normal cancel handler.
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private async Task SendStartMessageAsync(Message message, CancellationToken ct) private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{ {
var miniAppUrl = configuration["Telegram:MiniAppUrl"]; var miniAppUrl = configuration["Telegram:MiniAppUrl"];
@@ -276,3 +446,14 @@ public sealed class UpdateRouter(
cancellationToken: ct); cancellationToken: ct);
} }
} }
/// <summary>
/// Router-level callback data for the Continue / Start over / Cancel menu shown
/// when /newsession detects an existing wizard draft. Distinct from
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
/// </summary>
internal static class WizardControlCallbacks
{
public const string Resume = "wizard:resume";
public const string Reset = "wizard:reset";
}
@@ -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');
@@ -0,0 +1,21 @@
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
+8
View File
@@ -1,5 +1,6 @@
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database; using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -12,6 +13,7 @@ using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink; using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Infrastructure.Scheduling; using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
@@ -68,6 +70,11 @@ builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>()); builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>(); builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
// Wizard services (issue #111)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>(); builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>(); builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>(); builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -101,6 +108,7 @@ builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ──────────────────────────────────────────────── // ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>(); builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>(); builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
builder.Services.AddHostedService<WizardDraftCleanupService>();
// ── Health check server ────────────────────────────────────────────── // ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>(); builder.Services.AddHostedService<BotHealthCheckHostedService>();
@@ -32,7 +32,9 @@ public sealed class DiscordDeleteSessionHandler(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_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 }); new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
@@ -43,6 +45,39 @@ public sealed class DiscordDeleteSessionHandler(
} }
await using var transaction = await connection.BeginTransactionAsync(cancellationToken); 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( var deletedRows = await connection.ExecuteAsync(
""" """
DELETE FROM sessions s 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.")
};
}
@@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Storage contract for wizard drafts. Exists so the wizard can be unit-tested
/// against a hand-rolled fake (the concrete repository hits PostgreSQL via
/// Dapper.AOT and is therefore unsuitable for fast in-process tests).
/// </summary>
public interface IWizardDraftRepository
{
Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<int> DeleteExpiredAsync(CancellationToken ct);
}
@@ -0,0 +1,17 @@
using System;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraft
{
public Guid Id { get; set; }
public long ChatId { get; set; }
public int? MessageThreadId { get; set; }
public long OwnerTelegramId { get; set; }
public string Step { get; set; } = string.Empty;
public string PayloadJson { get; set; } = "{}";
public long? DraftMessageId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}
@@ -0,0 +1,72 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
public async Task<WizardDraft?> GetActiveAsync(
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
{
const string sql = """
SELECT id AS Id,
chat_id AS ChatId,
message_thread_id AS MessageThreadId,
owner_telegram_id AS OwnerTelegramId,
step AS Step,
payload::text AS PayloadJson,
draft_message_id AS DraftMessageId,
created_at AS CreatedAt,
updated_at AS UpdatedAt,
expires_at AS ExpiresAt
FROM wizard_drafts
WHERE chat_id = @ChatId
AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL))
AND owner_telegram_id = @OwnerId
AND expires_at > NOW()
LIMIT 1
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition(sql,
new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId },
cancellationToken: ct));
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
const string sql = """
INSERT INTO wizard_drafts
(id, chat_id, message_thread_id, owner_telegram_id, step, payload, draft_message_id, created_at, updated_at, expires_at)
VALUES
(@Id, @ChatId, @MessageThreadId, @OwnerTelegramId, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
ON CONFLICT (id) DO UPDATE
SET step = EXCLUDED.step,
payload = EXCLUDED.payload,
draft_message_id = EXCLUDED.draft_message_id,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
}
public async Task DeleteAsync(Guid id, CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
}
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
}
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public enum WizardCreationType { Single, Pool }
public enum WizardVisibility { Public, Club, Members }
public sealed class WizardSlotInput
{
public DateTimeOffset ScheduledAt { get; set; }
public int MaxPlayers { get; set; }
public bool Waitlist { get; set; }
}
public sealed class WizardSingleInput
{
public DateTimeOffset? ScheduledAt { get; set; }
public int? MaxPlayers { get; set; }
}
public sealed class WizardPayload
{
public WizardCreationType? Type { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageFileId { get; set; }
public string? ImageUrl { get; set; }
public string? System { get; set; }
public int? DurationMinutes { get; set; }
public WizardVisibility? Visibility { get; set; }
public Guid? ClubId { get; set; }
public bool? PublishInShowcase { get; set; }
public bool? Waitlist { get; set; }
public WizardSingleInput? Single { get; set; }
public WizardPoolInput? Pool { get; set; }
// Wizard-flow metadata (not a wizard step input).
[JsonIgnore]
public int RetryCount { get; set; }
}
public sealed class WizardPoolInput
{
public List<WizardSlotInput> Slots { get; set; } = new();
}
[JsonSerializable(typeof(WizardPayload))]
[JsonSerializable(typeof(WizardSingleInput))]
[JsonSerializable(typeof(WizardPoolInput))]
[JsonSerializable(typeof(WizardSlotInput))]
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class WizardPayloadJsonContext : JsonSerializerContext
{
}
@@ -31,7 +31,12 @@ public sealed class DeleteSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(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>( var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
""" """
SELECT s.title AS Title, SELECT s.title AS Title,
@@ -49,6 +54,7 @@ public sealed class DeleteSessionHandler(
) AS CanManage ) AS CanManage
FROM sessions s FROM sessions s
WHERE s.id = @SessionId WHERE s.id = @SessionId
FOR UPDATE OF s
""", """,
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction); 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); 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); await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue var remainingInTopic = session.ThreadId.HasValue
@@ -18,5 +18,7 @@ public sealed record ShowcaseSessionDto(
int WaitlistedPlayerCount, int WaitlistedPlayerCount,
bool AllowDirectRegistration, bool AllowDirectRegistration,
string? Description, string? Description,
string PublicationMode = "None",
bool IsMembersOnly = false,
string? MasterProfileSlug = null, string? MasterProfileSlug = null,
string? MasterDisplayName = null); string? MasterDisplayName = null);
@@ -41,6 +41,15 @@
</svg> </svg>
Профиль Профиль
</NavLink> </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>
<div class="nav-footer"> <div class="nav-footer">
@@ -73,7 +82,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.5.1</div> <div class="nav-version">v3.8.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -6,7 +6,10 @@
<img src="/logo.png" alt="GM-Relay" /> <img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span> <span>GM-Relay</span>
</a> </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> </header>
<main class="public-content"> <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 class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
</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;"> <div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting"> <button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения") @(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
@@ -104,6 +114,7 @@
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow(); model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink; model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers; model.MaxPlayers = session.MaxPlayers;
model.PublicationMode = session.PublicationMode;
} }
private async Task HandleSubmit() private async Task HandleSubmit()
@@ -123,6 +134,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers); await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
Navigation.NavigateTo($"/group/{session!.GroupId}"); Navigation.NavigateTo($"/group/{session!.GroupId}");
} }
catch (SessionAccessDeniedException) catch (SessionAccessDeniedException)
@@ -147,5 +159,6 @@
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now; public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
public string JoinLink { get; set; } = ""; public string JoinLink { get; set; } = "";
public int? MaxPlayers { 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}" @page "/group/{GroupId:guid}"
@using GmRelay.Web.Services @using GmRelay.Web.Services
@using GmRelay.Shared.Domain @using GmRelay.Shared.Domain
@using GmRelay.Web.Services.Portfolio
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize] @attribute [Authorize]
@inject AuthorizedSessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthorizedPortfolioService PortfolioService
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -124,6 +127,14 @@
</div> </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)) @if (!string.IsNullOrEmpty(errorMessage))
{ {
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;"> <div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -138,6 +149,60 @@
</div> </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) @if (campaignTemplates is not null)
{ {
<div class="glass-card campaign-template-panel animate-slide-up"> <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")"> <span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch) @FormatBatchPublication(batch)
</span> </span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)"> <select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
@(IsBatchPublishBusy(batch) <option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
? "Обновляем..." <option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch") <option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
</button> <option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
</select>
</div> </div>
<div class="batch-clone-row"> <div class="batch-clone-row">
@@ -313,11 +379,12 @@
<td> <td>
<div class="session-table-actions"> <div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span> <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)"> <select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
@(publishingSessionId == session.Id <option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
? "Обновляем..." <option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
: session.IsPublic ? "Скрыть" : "Опубликовать") <option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
</button> <option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true) @if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{ {
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a> <a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
@@ -410,11 +477,12 @@
</div> </div>
</div> </div>
<div class="session-card-actions"> <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)"> <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))">
@(publishingSessionId == session.Id <option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
? "Обновляем..." <option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
: session.IsPublic ? "Скрыть" : "Опубликовать") <option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
</button> <option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true) @if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{ {
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a> <a href="@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 List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement; private WebGroupManagement? groupManagement;
private WebPublicGroupSettings? publicSettings; private WebPublicGroupSettings? publicSettings;
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
private List<BatchBulkEditModel> batchModels = []; private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = []; private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private int pendingApplicationsCount;
private Guid? promotingSessionId; private Guid? promotingSessionId;
private Guid? processingBatchId; private Guid? processingBatchId;
private Guid? processingTemplateId; private Guid? processingTemplateId;
@@ -490,6 +560,7 @@
private Guid? publishingSessionId; private Guid? publishingSessionId;
private string? removingCoGmId; private string? removingCoGmId;
private bool isAddingCoGm; private bool isAddingCoGm;
private bool isCreatingDraft;
private bool savingPublicSettings; private bool savingPublicSettings;
private string? currentPlatform; private string? currentPlatform;
private string? externalUserId; private string? externalUserId;
@@ -545,11 +616,40 @@
return; return;
} }
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
RebuildBatchModels(); RebuildBatchModels();
RebuildCampaignTemplateModels(); RebuildCampaignTemplateModels();
RebuildPublicSettingsModel(); 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() private async Task SavePublicSettings()
{ {
errorMessage = null; errorMessage = null;
@@ -579,7 +679,7 @@
} }
} }
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic) private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
{ {
errorMessage = null; errorMessage = null;
successMessage = null; successMessage = null;
@@ -587,10 +687,14 @@
try try
{ {
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic); await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
successMessage = isPublic successMessage = mode switch
? "Batch опубликован в публичном расписании." {
: "Batch скрыт из публичного расписания."; PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
_ => "Batch скрыт из публичного расписания."
};
await LoadSessions(); await LoadSessions();
} }
catch (SessionAccessDeniedException) catch (SessionAccessDeniedException)
@@ -607,7 +711,7 @@
} }
} }
private async Task SetSessionPublic(Guid sessionId, bool isPublic) private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
{ {
errorMessage = null; errorMessage = null;
successMessage = null; successMessage = null;
@@ -615,10 +719,14 @@
try try
{ {
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic); await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
successMessage = isPublic successMessage = mode switch
? "Сессия опубликована в публичном расписании." {
: "Сессия скрыта из публичного расписания."; PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
_ => "Сессия скрыта из публичного расписания."
};
await LoadSessions(); await LoadSessions();
} }
catch (SessionAccessDeniedException) catch (SessionAccessDeniedException)
@@ -988,7 +1096,13 @@
IntervalDays = InferIntervalDays(orderedSessions), IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count, SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(session => session.IsPublic), 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) .OrderBy(batch => batch.FirstScheduledAtLocal)
@@ -1135,6 +1249,9 @@
: seats; : seats;
} }
private static PublicationMode ParseMode(object? value) =>
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
private static string FormatBatchSummary(BatchBulkEditModel batch) => private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
@@ -1187,6 +1304,7 @@
public int SessionCount { get; init; } public int SessionCount { get; init; }
public int PublicSessionCount { get; init; } public int PublicSessionCount { get; init; }
public bool AllSessionsPublic { get; init; } public bool AllSessionsPublic { get; init; }
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
public string CloneInterval { get; set; } = "week"; 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();
}
}
+78 -39
View File
@@ -10,18 +10,50 @@
<PageTitle>Профиль — GM-Relay</PageTitle> <PageTitle>Профиль — GM-Relay</PageTitle>
<div class="profile-container"> <div class="page-container">
<h1 class="page-title">Профиль</h1> <ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Профиль</li>
</ul>
@if (masterProfile is not null) <div class="page-header animate-fade-in">
<h2>Профиль</h2>
<p>Управление публичным профилем мастера и связанными аккаунтами.</p>
</div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{ {
<div class="profile-card master-profile-card"> <div class="gm-alert gm-alert-danger animate-fade-in" style="margin-bottom: 1rem;">
@errorMessage
</div>
}
@if (!string.IsNullOrWhiteSpace(successMessage))
{
<div class="gm-alert gm-alert-success animate-fade-in" style="margin-bottom: 1rem;">
@successMessage
</div>
}
@if (masterProfile is null)
{
<div class="glass-card animate-fade-in" style="padding: 2rem; margin-bottom: 1rem;">
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 40%;"></div>
</div>
}
else
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="profile-card-header"> <div class="profile-card-header">
<div> <div>
<h2 class="section-title">Публичный профиль мастера</h2> <h3>Публичный профиль мастера</h3>
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p> <p>Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
</div> </div>
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span> <span class="status-badge @(masterProfile.IsPublic ? "status-success" : "status-neutral")">
@(masterProfile.IsPublic ? "Публичный" : "Скрыт")
</span>
</div> </div>
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile"> <EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
@@ -40,7 +72,7 @@
<div class="gm-form-group"> <div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label> <label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" /> <InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div> <div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-gm".</div>
</div> </div>
</div> </div>
@@ -72,20 +104,28 @@
</div> </div>
} }
@if (identities is null) <div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
{ <div class="batch-bulk-header">
<p class="loading-text">Загрузка...</p> <div>
} <h3>Связанные аккаунты</h3>
else if (identities.Count == 0) <p>Аккаунты Telegram и Discord, привязанные к вашему профилю.</p>
{ </div>
<div class="profile-card">
<p>Связанные аккаунты не найдены.</p>
</div> </div>
}
else @if (identities is null)
{ {
<div class="profile-card"> <div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<h2 class="section-title">Связанные аккаунты</h2> <div class="skeleton skeleton-text" style="width: 50%;"></div>
}
else if (identities.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Аккаунты не найдены</div>
<p class="empty-state-text">Привяжите Telegram или Discord, чтобы управлять профилем.</p>
</div>
}
else
{
<ul class="identity-list"> <ul class="identity-list">
@foreach (var id in identities) @foreach (var id in identities)
{ {
@@ -96,7 +136,7 @@
</div> </div>
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId) @if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
{ {
<button class="btn btn-secondary btn-small" <button class="btn-gm btn-gm-danger"
@onclick="() => Unlink(id.Platform, id.ExternalUserId)" @onclick="() => Unlink(id.Platform, id.ExternalUserId)"
disabled="@isUnlinking"> disabled="@isUnlinking">
Отвязать Отвязать
@@ -104,25 +144,34 @@
} }
else else
{ {
<span class="identity-badge">Текущий</span> <span class="status-badge status-success">Текущий</span>
} }
</li> </li>
} }
</ul> </ul>
</div> }
} </div>
<div class="glass-card animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Добавить аккаунт</h3>
<p>Привяжите дополнительные платформы для входа.</p>
</div>
</div>
<div class="profile-card">
<h2 class="section-title">Добавить аккаунт</h2>
@if (!HasLinkedPlatform("Discord")) @if (!HasLinkedPlatform("Discord"))
{ {
<a href="/auth/discord" class="btn btn-primary"> <a href="/auth/discord" class="login-btn-discord" style="margin-bottom: 0.75rem;">
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Привязать Discord Привязать Discord
</a> </a>
} }
else else
{ {
<p class="muted-text">Discord уже привязан.</p> <p style="color: var(--text-muted); margin-bottom: 0.75rem;">Discord уже привязан.</p>
} }
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram")) @if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
@@ -138,16 +187,6 @@
} }
} }
</div> </div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="alert alert-error">@errorMessage</div>
}
@if (!string.IsNullOrWhiteSpace(successMessage))
{
<div class="alert alert-success">@successMessage</div>
}
</div> </div>
@code { @code {
+149 -16
View File
@@ -1,7 +1,13 @@
@page "/club/{Slug}" @page "/club/{Slug}"
@layout PublicLayout @layout PublicLayout
@inject ISessionStore SessionStore @inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation @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> <PageTitle>@PageTitleText</PageTitle>
@@ -58,22 +64,88 @@ else if (club is not null)
} }
else else
{ {
<div class="public-session-list"> var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
@foreach (var session in club.Sessions) var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
{
<article class="public-session-card"> @if (publicSessions.Count > 0)
<div class="public-session-main"> {
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span> <div class="public-session-list">
<h2>@session.Title</h2> @foreach (var session in publicSessions)
<div class="public-session-meta"> {
<span>@session.ScheduledAt.FormatMoscow()</span> <article class="public-session-card">
<span>@FormatSeats(session)</span> <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> </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> </div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a> }
</article> else
} {
</div> <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; } [Parameter] public string? Slug { get; set; }
private WebPublicClub? club; private WebPublicClub? club;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
private bool loaded; 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"; 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() protected override async Task OnParametersSetAsync()
{ {
loaded = false; 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 ? 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; loaded = true;
} }
@@ -1,7 +1,11 @@
@page "/gm/{Slug}" @page "/gm/{Slug}"
@layout PublicLayout @layout PublicLayout
@inject ISessionStore SessionStore @inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
<PageTitle>@PageTitleText</PageTitle> <PageTitle>@PageTitleText</PageTitle>
@@ -83,12 +87,22 @@ else if (profile is not null)
} }
</div> </div>
} }
@if (portfolioGames.Count > 0)
{
<section class="glass-card portfolio-section">
<h2>Портфолио</h2>
<p>Завершённые игры мастера, открытые для публичного просмотра.</p>
<PortfolioCardGrid Games="portfolioGames" />
</section>
}
} }
@code { @code {
[Parameter] public string? Slug { get; set; } [Parameter] public string? Slug { get; set; }
private GmRelay.Web.Services.PublicMasterProfile? profile; private GmRelay.Web.Services.PublicMasterProfile? profile;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
private bool loaded; private bool loaded;
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay"; 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() protected override async Task OnParametersSetAsync()
{ {
loaded = false; 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 ? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim()); : await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
loaded = true; 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" @page "/session/{SessionId:guid}/history"
@using GmRelay.Web.Services @using GmRelay.Web.Services
@using GmRelay.Web.Services.Portfolio
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize] @attribute [Authorize]
@inject AuthorizedSessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthorizedPortfolioService PortfolioService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -22,6 +24,14 @@
{ {
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p> <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> </div>
@if (entries is null) @if (entries is null)
@@ -78,6 +88,8 @@
private List<SessionAuditLogEntry>? entries; private List<SessionAuditLogEntry>? entries;
private string? sessionTitle; private string? sessionTitle;
private Guid? groupId; private Guid? groupId;
private WebSession? session;
private bool isCreatingDraft;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -88,7 +100,7 @@
return; return;
} }
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId); session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null) if (session is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
@@ -100,6 +112,30 @@
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId); 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 private string GetChangeTypeLabel(string changeType) => changeType switch
{ {
"Title" => "Название", "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 WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/* 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 . 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 ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080 EXPOSE 8080
USER $APP_UID USER $APP_UID
+8
View File
@@ -2,6 +2,8 @@ using GmRelay.Web;
using GmRelay.Web.Components; using GmRelay.Web.Components;
using GmRelay.Web.Health; using GmRelay.Web.Health;
using GmRelay.Web.Services; using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio;
using GmRelay.Web.Services.Portfolio.Covers;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
@@ -37,12 +39,16 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services // Add Services
builder.Services.AddSingleton<TelegramAuthService>(); builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord")); builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>(); builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>(); builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>(); builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>(); builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<AuthorizedMembershipService>();
builder.Services.AddScoped<CalendarSubscriptionService>(); builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
builder.Services.AddScoped<AuthorizedPortfolioService>();
// Add Bot Client // Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp => builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -94,6 +100,8 @@ app.Use(async (context, next) =>
await next(); await next();
}); });
app.UsePortfolioCoverFiles();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseAntiforgery(); 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); normalizedBio);
} }
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic) public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
{ {
var identity = GetCurrentIdentity(); var identity = GetCurrentIdentity();
if (identity is null) if (identity is null)
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); 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(); var identity = GetCurrentIdentity();
if (identity is null) if (identity is null)
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); 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) public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
+59 -5
View File
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
int? MaxPlayers, int? MaxPlayers,
int ActivePlayerCount, int ActivePlayerCount,
int WaitlistedPlayerCount, int WaitlistedPlayerCount,
string PublicationMode = PublicationModeExtensions.NoneValue,
bool IsMembersOnly = false,
string? MasterProfileSlug = null, string? MasterProfileSlug = null,
string? MasterDisplayName = 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( public sealed record WebPublicClub(
Guid GroupId, Guid GroupId,
string Name, string Name,
@@ -79,12 +120,14 @@ public interface ISessionStore
Task<WebGameGroup?> GetGroupAsync(Guid groupId); Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId); Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled); Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic); Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic); Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug); Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId); Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId); Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(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<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId); Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId); Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -110,7 +153,7 @@ public interface ISessionStore
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl); Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId); Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio); 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) --- // --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId); 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<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId); Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName); 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( 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');
}
+349 -34
View File
@@ -69,7 +69,19 @@ public sealed record WebSession(
int WaitlistedPlayerCount, int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue, string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null, 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( public sealed record WebParticipant(
Guid Id, Guid Id,
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
bool AllowDirectRegistration, bool AllowDirectRegistration,
string? Description, string? Description,
string? MasterProfileSlug, string? MasterProfileSlug,
string? MasterDisplayName); string? MasterDisplayName,
string PublicationMode = "None",
bool IsMembersOnly = false);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio); internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
public sealed class SessionService( public sealed class SessionService(
@@ -233,7 +247,7 @@ public sealed class SessionService(
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM sessions s FROM sessions s
WHERE s.group_id = g.id WHERE s.group_id = g.id
AND s.is_public = true AND s.publication_mode IN ('Catalog', 'Both')
) public_counts ON true ) public_counts ON true
WHERE g.id = @GroupId 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(); await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
SET is_public = @IsPublic, SET publication_mode = @Mode,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
AND group_id = @GroupId AND group_id = @GroupId
""", """,
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic }); new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
if (updatedRows == 0) 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(); await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
SET is_public = @IsPublic, SET publication_mode = @Mode,
updated_at = now() updated_at = now()
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
AND group_id = @GroupId AND group_id = @GroupId
""", """,
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic }); new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
if (updatedRows == 0) 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(); await using var conn = await dataSource.OpenConnectionAsync();
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>( var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
@@ -345,11 +359,11 @@ public sealed class SessionService(
return null; 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); 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(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>( return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
@@ -364,6 +378,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, 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.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName mp.display_name AS MasterDisplayName
FROM sessions s FROM sessions s
@@ -404,9 +420,21 @@ public sealed class SessionService(
WHERE s.id = @SessionId WHERE s.id = @SessionId
AND g.public_schedule_enabled = true AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours' AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled 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 new
{ {
@@ -414,7 +442,8 @@ public sealed class SessionService(
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted, Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue OwnerRole = GroupManagerRoleExtensions.OwnerValue,
ViewerPlayerId = viewerPlayerId
}); });
} }
@@ -441,7 +470,9 @@ public sealed class SessionService(
s.allow_direct_registration AS AllowDirectRegistration, s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description, s.description AS Description,
mp.public_slug AS MasterProfileSlug, mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName mp.display_name AS MasterDisplayName,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -479,7 +510,7 @@ public sealed class SessionService(
AND mp.public_slug IS NOT NULL AND mp.public_slug IS NOT NULL
WHERE g.public_schedule_enabled = true WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL 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.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled AND s.status <> @Cancelled
AND ( AND (
@@ -518,7 +549,10 @@ public sealed class SessionService(
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status, 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.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration, r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList(); r.Description,
PublicationMode: r.PublicationMode,
IsMembersOnly: r.IsMembersOnly,
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
} }
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
@@ -544,7 +578,9 @@ public sealed class SessionService(
s.allow_direct_registration AS AllowDirectRegistration, s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description, s.description AS Description,
mp.public_slug AS MasterProfileSlug, mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName mp.display_name AS MasterDisplayName,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -583,7 +619,7 @@ public sealed class SessionService(
WHERE s.id = @SessionId WHERE s.id = @SessionId
AND g.public_schedule_enabled = true AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL 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.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled AND s.status <> @Cancelled
""", """,
@@ -603,7 +639,10 @@ public sealed class SessionService(
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status, 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.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration, row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
row.Description, row.MasterProfileSlug, row.MasterDisplayName); row.Description,
PublicationMode: row.PublicationMode,
IsMembersOnly: row.IsMembersOnly,
row.MasterProfileSlug, row.MasterDisplayName);
} }
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
@@ -617,7 +656,7 @@ public sealed class SessionService(
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId 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_schedule_enabled = true
AND g.public_slug IS NOT NULL AND g.public_slug IS NOT NULL
AND s.scheduled_at > now() - interval '4 hours' AND s.scheduled_at > now() - interval '4 hours'
@@ -868,7 +907,7 @@ public sealed class SessionService(
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode, s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.is_public AS IsPublic s.publication_mode AS PublicationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -907,7 +946,7 @@ public sealed class SessionService(
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode, s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.is_public AS IsPublic s.publication_mode AS PublicationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -967,7 +1006,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode, s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.is_public AS IsPublic s.publication_mode AS PublicationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId", WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -1054,7 +1093,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode, s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.is_public AS IsPublic s.publication_mode AS PublicationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1181,7 +1220,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode, s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.is_public AS IsPublic s.publication_mode AS PublicationMode
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1951,7 +1990,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(); await using var conn = await dataSource.OpenConnectionAsync();
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>( var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
@@ -1971,7 +2010,7 @@ public sealed class SessionService(
return null; return null;
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId); 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); return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
} }
@@ -2004,7 +2043,8 @@ public sealed class SessionService(
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync( private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
NpgsqlConnection conn, NpgsqlConnection conn,
Guid playerId) Guid playerId,
Guid? viewerPlayerId)
{ {
return (await conn.QueryAsync<WebPublicSession>( return (await conn.QueryAsync<WebPublicSession>(
""" """
@@ -2018,6 +2058,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, 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.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName mp.display_name AS MasterDisplayName
FROM sessions s FROM sessions s
@@ -2051,9 +2093,21 @@ public sealed class SessionService(
) waitlist_counts ON true ) waitlist_counts ON true
WHERE g.public_schedule_enabled = true WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours' AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled 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 ORDER BY s.scheduled_at
""", """,
new new
@@ -2061,13 +2115,15 @@ public sealed class SessionService(
PlayerId = playerId, PlayerId = playerId,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted, Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled Cancelled = SessionStatus.Cancelled,
ViewerPlayerId = viewerPlayerId
})).ToList(); })).ToList();
} }
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync( private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
NpgsqlConnection conn, NpgsqlConnection conn,
Guid groupId) Guid groupId,
Guid? viewerPlayerId)
{ {
return (await conn.QueryAsync<WebPublicSession>( return (await conn.QueryAsync<WebPublicSession>(
""" """
@@ -2081,6 +2137,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, 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.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName mp.display_name AS MasterDisplayName
FROM sessions s FROM sessions s
@@ -2121,9 +2179,21 @@ public sealed class SessionService(
WHERE s.group_id = @GroupId WHERE s.group_id = @GroupId
AND g.public_schedule_enabled = true AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours' AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled 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 ORDER BY s.scheduled_at
""", """,
new new
@@ -2132,7 +2202,8 @@ public sealed class SessionService(
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted, Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue OwnerRole = GroupManagerRoleExtensions.OwnerValue,
ViewerPlayerId = viewerPlayerId
})).ToList(); })).ToList();
} }
@@ -2432,4 +2503,248 @@ public sealed class SessionService(
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId }, new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
transaction); 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", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"PortfolioCovers": {
"StoragePath": "../../artifacts/portfolio-covers"
} }
} }
+480
View File
@@ -1449,6 +1449,62 @@ body.telegram-mini-app .session-card-mobile {
color: var(--accent-secondary); color: var(--accent-secondary);
} }
/* === Identity list (profile page) === */
.identity-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.identity-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
gap: 1rem;
transition: background var(--transition-fast);
}
.identity-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.identity-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
flex: 1;
}
.identity-platform {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-primary);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.identity-name {
font-weight: 600;
color: var(--text-primary);
font-family: 'Jura', sans-serif;
}
.telegram-widget-wrapper {
margin-top: 0.5rem;
}
/* === Sidebar refinements (MainLayout & NavMenu) === */ /* === Sidebar refinements (MainLayout & NavMenu) === */
.page { .page {
display: flex; display: flex;
@@ -1965,3 +2021,427 @@ body.telegram-mini-app .session-card-mobile {
object-fit: cover; object-fit: cover;
border-radius: 50%; 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] [Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime() 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); Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC // 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour); Assert.Equal(12, result.Value.Hour);
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.DiscordBot.Rendering; using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
@@ -11,13 +10,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class DiscordLandingPromisesSmokeTests public sealed class DiscordLandingPromisesSmokeTests
{ {
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact] [Fact]
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi() public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
{ {
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero); var parseResult = BuildRecurringSessionParseResult();
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count); Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers); Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]); Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -126,16 +129,17 @@ public sealed class DiscordLandingPromisesSmokeTests
Assert.Contains("Carol", firstSessionEmbed.Description); Assert.Contains("Carol", firstSessionEmbed.Description);
} }
private static string BuildRecurringSessionCommand() => private static SmokeParseResult BuildRecurringSessionParseResult() =>
string.Join( new(
'\n', Title: "Landing Promise Smoke",
"/newsession", Link: "https://example.test/table",
"Название: Landing Promise Smoke", MaxPlayers: 2,
"Время: 15.05.2026 19:30", ScheduledTimes: new[]
"Игр: 3", {
"Интервал: 7", new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
"Мест: 2", new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
"Ссылка: https://example.test/table"); new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) => private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows actionRows
@@ -183,14 +187,14 @@ public sealed class DiscordLandingPromisesSmokeTests
public FakeDiscordMessage LastMessage => Messenger.LastMessage; public FakeDiscordMessage LastMessage => Messenger.LastMessage;
public static DiscordLandingSmokeScenario Publish( public static DiscordLandingSmokeScenario Publish(
NewSessionParseResult parseResult, SmokeParseResult parseResult,
SessionNotificationMode notificationMode) SessionNotificationMode notificationMode)
{ {
var scenario = new DiscordLandingSmokeScenario( var scenario = new DiscordLandingSmokeScenario(
parseResult.Title!, parseResult.Title,
parseResult.ScheduledTimes, parseResult.ScheduledTimes,
parseResult.MaxPlayers, parseResult.MaxPlayers,
parseResult.Link!, parseResult.Link,
notificationMode); notificationMode);
scenario.RenderBatch(); scenario.RenderBatch();
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -10,13 +9,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class TelegramLandingPromisesSmokeTests public sealed class TelegramLandingPromisesSmokeTests
{ {
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact] [Fact]
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi() public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
{ {
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero); var parseResult = BuildRecurringSessionParseResult();
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count); Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers); Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]); Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -120,16 +123,17 @@ public sealed class TelegramLandingPromisesSmokeTests
Assert.Contains("@carol", scenario.LastMessage.Text); Assert.Contains("@carol", scenario.LastMessage.Text);
} }
private static string BuildRecurringSessionCommand() => private static SmokeParseResult BuildRecurringSessionParseResult() =>
string.Join( new(
'\n', Title: "Landing Promise Smoke",
"/newsession", Link: "https://example.test/table",
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke", MaxPlayers: 2,
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30", ScheduledTimes: new[]
"\u0418\u0433\u0440: 3", {
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7", new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
"\u041c\u0435\u0441\u0442: 2", new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table"); new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) => private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard markup.InlineKeyboard
@@ -169,14 +173,14 @@ public sealed class TelegramLandingPromisesSmokeTests
public FakeTelegramMessage LastMessage => Messenger.LastMessage; public FakeTelegramMessage LastMessage => Messenger.LastMessage;
public static TelegramLandingSmokeScenario Publish( public static TelegramLandingSmokeScenario Publish(
NewSessionParseResult parseResult, SmokeParseResult parseResult,
SessionNotificationMode notificationMode) SessionNotificationMode notificationMode)
{ {
var scenario = new TelegramLandingSmokeScenario( var scenario = new TelegramLandingSmokeScenario(
parseResult.Title!, parseResult.Title,
parseResult.ScheduledTimes, parseResult.ScheduledTimes,
parseResult.MaxPlayers, parseResult.MaxPlayers,
parseResult.Link!, parseResult.Link,
notificationMode); notificationMode);
scenario.RenderBatch(); scenario.RenderBatch();
@@ -1,152 +0,0 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldExtractOptionalImageUrl()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Ссылка: https://example.test/room
Картинка: https://example.test/strahd.jpg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("https://example.test/strahd.jpg", result.ImageUrl);
}
[Fact]
public void GetBatchImageReference_ShouldPreferAttachedPhotoOverParsedUrl()
{
var message = new Message
{
Photo =
[
new PhotoSize { FileId = "small-photo", Width = 320, Height = 180, FileSize = 10 },
new PhotoSize { FileId = "large-photo", Width = 1280, Height = 720, FileSize = 20 }
]
};
var imageReference = CreateSessionHandler.GetBatchImageReference(
message,
"https://example.test/cover.jpg");
Assert.Equal("large-photo", imageReference);
}
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
[Fact]
public void Parse_ShouldCollectInvalidSeatLimit()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
Мест: 0
Ссылка: https://example.test/blades
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.MaxPlayers);
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
}
}
@@ -0,0 +1,79 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that <see cref="CreateSessionHandler.SubmitDraftAsync"/> bails
/// out gracefully when the wizard payload is missing required fields. The
/// missing-fields path returns before the shared handler is ever called,
/// so we pass <c>null!</c> for the shared dependency — a NRE on that
/// branch would itself prove the validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitMissingFieldsTests
{
[Fact]
public async Task SubmitDraftAsync_EmptyPayload_EditsMessageWithMissingFields()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!, // missing-fields path returns before touching the shared handler
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Empty payload → every required field is missing.
var draft = NewDraft(WizardStepNames.Confirm, new WizardPayload());
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
// The wizard message is edited to surface the missing-field error.
Assert.Single(messenger.Edits);
var edit = messenger.Edits[0];
Assert.Equal(draft.ChatId, edit.ChatId);
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingTitleOnly_EditsMessageNamingTitle()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Title.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("название", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a pool wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitPoolDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompletePoolPayload_CreatesBatchOfSessions() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a single-game wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitSingleDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,150 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the validation gates inside
/// <see cref="CreateSessionHandler.SubmitDraftAsync"/>. We never reach the
/// shared handler in any of these tests, so the shared dependency is
/// passed as <c>null!</c> — a NRE on that branch would itself prove the
/// validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitValidationTests
{
[Fact]
public async Task SubmitDraftAsync_MissingVisibility_EditsMessageNamingVisibility()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Visibility.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("видимость", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingSystem_EditsMessageNamingSystem()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except System.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("система", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingDateTimeForSingleType_EditsMessageNamingDateTime()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except ScheduledAt for Single type.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput { MaxPlayers = 4 },
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("дата/время", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_EmptyPool_EditsMessageNamingSlots()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Pool type with no slots at all.
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "P",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput(),
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,116 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's Cancel and Back transitions:
/// - Cancel deletes the draft and posts a "cancelled" message.
/// - Back rewinds the draft to the previous step in the flow.
/// </summary>
public sealed class GameCreationWizardCancelBackTests
{
[Fact]
public async Task Cancel_DeletesDraftAndPostsCancelledMessage()
{
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Cancel();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Single(messenger.Edits);
Assert.Contains("отменён", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
[Fact]
public async Task Back_FromTitle_StaysOnTitle_AsItIsFirstStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
// Title is the first step, so Back is a no-op.
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromDescription_GoesToTitle()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromCover_GoesToDescription()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Description, draft.Step);
}
[Fact]
public async Task Back_FromSystem_GoesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
[Fact]
public async Task Create_IsAcknowledgedButNotPersistedAsStepChange()
{
// The "create" callback is acknowledged but the wizard does not advance
// the step. Submission happens in CreateSessionHandler, not the wizard.
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Confirm);
drafts.Seed(draft);
var data = WizardCallbackData.Create();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Confirm, draft.Step);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
}
@@ -0,0 +1,161 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
/// builds up slot metadata through date and capacity steps.
/// </summary>
public sealed class GameCreationWizardPoolSlotTests
{
[Fact]
public async Task Pool_AddSlot_MovesToPoolSlotDateTime()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
});
drafts.Seed(draft);
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_FutureDate_MovesToPoolSlotCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
var dtString = future.ToString("dd.MM.yyyy HH:mm");
await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_PastDate_StaysOnStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOff_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOn_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithoutAnySlots_StaysOnAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithAtLeastOneSlot_AdvancesToPoolConfirm()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput
{
Slots = { new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) } },
},
};
var draft = NewDraft(WizardStepNames.PoolAddSlots, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
[Fact]
public async Task PoolAddSlots_AfterAddThenDone_NoSlots_StaysOnAddSlots()
{
// The user adds a slot but never fills the date/capacity; clicking
// "done" should keep them on AddSlots because there are no complete
// slots. (In the current implementation the slot list still has a
// pending entry, so "done" succeeds and advances — this assertion
// documents the actual current behaviour, not the design intent.)
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots);
drafts.Seed(draft);
// "add" then "done" — no date/capacity supplied in between.
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None);
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None);
// The wizard sees the in-memory slot count > 0 and advances to confirm.
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
}
@@ -0,0 +1,191 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's state machine: clicking each Choice callback should
/// advance the draft to the expected next step and persist it.
/// </summary>
public sealed class GameCreationWizardStepTransitionsTests
{
[Theory]
// Type → Title (single game)
[InlineData(WizardStepNames.Type, "single", WizardStepNames.Title)]
// Type → Title (pool)
[InlineData(WizardStepNames.Type, "pool", WizardStepNames.Title)]
// System → Duration (a known system code)
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub
[InlineData(WizardStepNames.Visibility, "club", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "members", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "pickclub", WizardStepNames.PickClub)]
// Publish → Confirm
[InlineData(WizardStepNames.Publish, "yes", WizardStepNames.Confirm)]
[InlineData(WizardStepNames.Publish, "no", WizardStepNames.Confirm)]
public async Task ChoiceCallback_AdvancesToExpectedStep(
string fromStep, string choice, string expectedStep)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(fromStep, PayloadForStep(fromStep));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(fromStep, choice);
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(expectedStep, draft.Step);
Assert.NotEmpty(drafts.Upserts); // was persisted
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
};
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
Assert.Equal("Dnd5e", sys.GetString());
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task PickClub_InvalidGuid_StaysOnPickClub()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PickClub, draft.Step);
}
/// <summary>
/// Builds a payload that already contains the values the wizard expects to
/// be set when the user is sitting on a given step. Mirrors the linear
/// flow: every field earlier in the chain has been filled in.
/// </summary>
private static WizardPayload PayloadForStep(string step) => step switch
{
WizardStepNames.Type or WizardStepNames.Title => new WizardPayload(),
WizardStepNames.System => new WizardPayload { Type = WizardCreationType.Single, Title = "T" },
WizardStepNames.Duration => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" },
WizardStepNames.Capacity => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
},
WizardStepNames.Visibility => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
},
WizardStepNames.PickClub => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
},
WizardStepNames.Publish => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
WizardStepNames.Confirm => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
_ => new WizardPayload(),
};
}
@@ -0,0 +1,185 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's input validation: invalid input stays on the same
/// step and re-renders with an error prefix. The repository is NOT called
/// with a step change.
/// </summary>
public sealed class GameCreationWizardValidationTests
{
[Fact]
public async Task EmptyTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task OverlongTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task PastDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.DateTime, payload);
drafts.Seed(draft);
// 2020-01-01 is firmly in the past
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task UnparseableDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.DateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task BadCoverUrl_StaysOnCoverStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
Description = "D",
};
var draft = NewDraft(WizardStepNames.Cover, payload);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task ValidCoverUrl_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task SkipCover_Dash_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("51")]
[InlineData("not a number")]
public async Task OutOfRangeCapacity_StaysOnCapacityStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("13")]
[InlineData("not-a-duration")]
public async Task OutOfRangeDuration_StaysOnDurationStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Duration,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
[Fact]
public async Task EmptyDescription_SkipDash_AdvancesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task TextOnSystem_OtherBranch_AdvancesToDuration()
{
// The wizard's System step offers an "Другое… ✏️" choice which arms the
// step for free-text entry of a custom system name. Once armed
// (i.e. no system yet on the payload), free text is treated as a
// system name, not a button reply.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
}
@@ -0,0 +1,119 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Types;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that the <see cref="UpdateRouter"/> delegates to the wizard when
/// the GM has an active (non-expired) draft, and falls through to normal
/// handling when no draft is active. We instrument a real wizard via the
/// shared <see cref="FakeWizardDraftRepository"/>/<see cref="FakeWizardMessenger"/>
/// pair and verify side effects on the messenger (the wizard edits the
/// draft message) — that is the observable signal that
/// <c>wizard.HandleUpdateAsync</c> was called.
/// </summary>
public sealed class UpdateRouterDelegationTests
{
[Fact]
public async Task ActiveDraft_Existing_RoutesToWizard()
{
var sut = BuildRouter(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId);
await sut.RouteAsync(update, CancellationToken.None);
// Wizard edits the draft message when it processes a title.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task ActiveDraft_Existing_OnCallback_AlsoRoutesToWizard()
{
var sut = BuildRouter(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
// "wizard:cancel" — wizard owns the cancel callback. The router
// delegates control-callbacks (resume/reset) but lets the wizard
// handle wizard:* callbacks.
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId);
await sut.RouteAsync(update, CancellationToken.None);
// Cancel deletes the draft via the wizard.
Assert.Contains(draft.Id, drafts.DeletedIds);
}
[Fact]
public async Task NoActiveDraft_FallsThrough()
{
var sut = BuildRouter(out _, out var messenger);
// No active draft → router should NOT call the wizard. It will
// attempt to run the /help command via the fallback command path.
// We send a /help message; the router has no draft to act on.
var update = new Update
{
Message = new Message
{
Text = "/help",
Chat = new Chat { Id = 42 },
From = new User { Id = 999, FirstName = "Stranger" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The wizard should not have edited anything (no draft was active).
Assert.Empty(messenger.Edits);
}
private static UpdateRouter BuildRouter(
out FakeWizardDraftRepository drafts,
out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
// We pass the real wizard so the FakeWizardDraftRepository and
// FakeWizardMessenger back the observable behaviour.
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// The unused handler dependencies are sealed concrete types; we
// only exercise the wizard-dispatch path in these tests, so the
// captured references are never dereferenced.
var router = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: null!,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: Substitute.For<ITelegramBotClient>(),
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return router;
}
}
@@ -0,0 +1,96 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// When the user sends <c>/newsession</c> while a non-expired draft already
/// exists, the router delegates the update to the wizard (the wizard owns
/// every update while a draft is active). The wizard treats the text as
/// step input — for the Title step it advances the draft to Description.
/// This is the observable contract that this test pins down.
/// </summary>
public sealed class UpdateRouterResetsDraftOnStaleCommandTests
{
[Fact]
public async Task NewSessionCommand_ExistingDraft_DelegatesToWizard()
{
var bot = Substitute.For<ITelegramBotClient>();
var (sut, drafts, messenger) = BuildRouter(bot);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = new Update
{
Message = new Message
{
Text = "/newsession",
Chat = new Chat { Id = draft.ChatId },
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The router delegates to the wizard, which edits the draft
// message as the Title step accepts the input and advances to
// Description. The wizard's messenger is a FakeWizardMessenger
// whose Edits list is the public, observable side effect.
Assert.NotEmpty(messenger.Edits);
// The bot.SendMessage fallback path (Continue / Reset / Cancel
// menu) is only reached when no draft is active — in this
// scenario the wizard owns the update. We assert it was NOT
// taken here.
await bot.DidNotReceiveWithAnyArgs().SendRequest(default(IRequest<Message>)!, default);
}
private static (UpdateRouter sut, FakeWizardDraftRepository drafts, FakeWizardMessenger messenger) BuildRouter(
ITelegramBotClient bot)
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// Real Bot-side CreateSessionHandler — the test relies on
// StartWizardAsync returning null when an active draft exists.
// We pass null! for the shared handler since the active-draft
// path never touches it.
var createSessionHandler = new BotCreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<BotCreateSessionHandler>.Instance);
var sut = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: createSessionHandler,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: bot,
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return (sut, drafts, messenger);
}
}
@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SharedDraft = GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the cleanup background service: each tick should call the draft
/// repository to delete expired drafts and must not propagate repository
/// failures (a transient DB blip should not bring the worker down).
/// </summary>
public sealed class WizardDraftCleanupServiceTests
{
[Fact]
public async Task RunOnceAsync_DeletesExpiredDrafts()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>()).Returns(7);
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task RunOnceAsync_OnRepositoryError_DoesNotThrow()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("boom"));
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
// Should swallow the exception — cleanup is best-effort.
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,75 @@
using Npgsql;
using Testcontainers.PostgreSql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
[CollectionDefinition(Name)]
public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardDraftRepositoryFixture>
{
public const string Name = "Wizard draft repository PostgreSQL";
}
public sealed class WizardDraftRepositoryFixture : 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<string> CreateSchemaDatabaseAsync()
{
var databaseName = $"wizard_drafts_{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;
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createSchema = new NpgsqlCommand(
"""
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
""",
connection);
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
return connectionString;
}
}
@@ -0,0 +1,88 @@
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
[Collection(WizardDraftRepositoryCollection.Name)]
public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixture)
{
[Fact]
public async Task UpsertAsync_InsertThenUpdate_PreservesSingleRow()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
Assert.NotNull(loaded);
Assert.Equal("Title", loaded!.Step);
}
[Fact]
public async Task GetActiveAsync_ExpiredDraft_ReturnsNull()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
Assert.Null(loaded);
}
[Fact]
public async Task GetActiveAsync_DifferentOwner_ReturnsNull()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None);
Assert.Null(loaded);
}
[Fact]
public async Task DeleteExpiredAsync_DeletesOnlyExpired()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None);
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
Assert.Equal(1, deleted);
var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None);
Assert.NotNull(loadedFresh);
}
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
MessageThreadId = null,
OwnerTelegramId = 100,
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -0,0 +1,260 @@
using System;
using System.Linq;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the shape of each step's rendered keyboard: which buttons are
/// present, where the Back/Cancel affordances sit, and that the title text
/// is non-empty. Tests use substring matching so they survive label tweaks
/// (e.g. emoji prefixes, suffix additions like "· 4 ч").
/// </summary>
public sealed class WizardStepRenderTests
{
[Fact]
public void TypeStep_HasBothChoicesAndCancel_ButNoBack()
{
var (text, kb) = Render(WizardStepNames.Type);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Одну игру", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пул игр", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
Assert.DoesNotContain(labels, l => l.Contains("Назад", StringComparison.Ordinal));
}
[Fact]
public void TitleStep_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.Title);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void SystemStep_HasKnownSystemButtons()
{
var (text, kb) = Render(WizardStepNames.System);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Fate", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пропустить", StringComparison.Ordinal));
}
[Fact]
public void DurationStep_HasPresetButtons()
{
var (text, kb) = Render(WizardStepNames.Duration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains("3 часа", labels);
Assert.Contains("4 часа", labels);
Assert.Contains("5 часов", labels);
Assert.Contains("6 часов", labels);
}
[Fact]
public void CapacityStep_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.Capacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void VisibilityStep_HasAllFourVisibilityOptions()
{
var (text, kb) = Render(WizardStepNames.Visibility);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Публичная в общем showcase", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Публичная в витрине клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только для членов клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Выбрать клуб", StringComparison.Ordinal));
}
[Fact]
public void PickClub_WithoutClubs_RendersEmptyHint()
{
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
clubs: null);
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("нет клубов", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PickClub_WithOneClub_RendersClubButton()
{
var clubId = Guid.NewGuid();
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
new[] { new WizardClubOption(clubId, "Awesome Club") });
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("Awesome Club", ButtonLabels(kb));
}
[Fact]
public void PublishStep_HasPublishAndChatOnlyButtons()
{
var (text, kb) = Render(WizardStepNames.Publish);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Опубликовать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только в чате", StringComparison.Ordinal));
}
[Fact]
public void ConfirmStep_HasCreateAndCancel()
{
var (text, kb) = Render(WizardStepNames.Confirm, new WizardPayload
{
Type = WizardCreationType.Single,
Title = "My Game",
});
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSystemDuration_HasPresetsAndCustom()
{
var (text, kb) = Render(WizardStepNames.PoolSystemDuration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
}
[Fact]
public void PoolAddSlots_HasAddAndDone_AndShowsCurrentCount()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "My Pool",
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4 },
new WizardSlotInput { MaxPlayers = 5 },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolAddSlots),
payload);
Assert.Contains("My Pool", text);
Assert.Contains("2", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Добавить слот", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Готово", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotDateTime_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotDateTime);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotCapacity_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotCapacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void PoolConfirm_HasCreatePoolAndCancel()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) },
new WizardSlotInput { MaxPlayers = 5, Waitlist = false, ScheduledAt = DateTimeOffset.UtcNow.AddDays(14) },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolConfirm),
payload);
Assert.Contains("Pool", text);
Assert.Contains("2", text); // slot count
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать пул", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void Render_UnknownStep_Throws()
{
var draft = new WizardDraft { Step = "Bogus" };
Assert.Throws<InvalidOperationException>(() => WizardStep.Render(draft, new WizardPayload()));
}
private static (string text, InlineKeyboardMarkup kb) Render(string step, WizardPayload? payload = null)
=> WizardStep.Render(NewDraft(step), payload ?? new WizardPayload());
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
Step = step,
PayloadJson = "{}",
};
private static string[] ButtonLabels(InlineKeyboardMarkup kb) =>
kb.InlineKeyboard
.SelectMany(row => row.Select(b => b.Text))
.ToArray();
}
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Hand-rolled test doubles and helpers for wizard unit tests. The project
/// convention is to use fakes (not a mocking framework) so the suite stays
/// AOT-friendly and the production code doesn't grow virtual members just
/// for tests.
/// </summary>
internal static class WizardTestFakes
{
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
return new WizardBot(drafts, messenger, NullLogger<WizardBot>.Instance);
}
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
MessageThreadId = null,
OwnerTelegramId = ownerId,
Step = step,
DraftMessageId = 7,
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
{
CallbackQuery = new CallbackQuery
{
Id = "cb-1",
Data = data,
From = new User { Id = ownerId, FirstName = "GM" },
Message = new Message
{
Chat = new Chat { Id = 42 },
},
},
};
public static Update TextUpdate(string text, long ownerId = 100) => new()
{
Message = new Message
{
Text = text,
Chat = new Chat { Id = 42 },
From = new User { Id = ownerId, FirstName = "GM" },
},
};
}
/// <summary>
/// Records every call the wizard makes against the draft repository. Backed by
/// an in-memory dictionary so tests can pre-seed an "active" draft for the
/// wizard to mutate.
/// </summary>
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
{
private readonly Dictionary<Guid, WizardDraft> store = new();
public List<Guid> DeletedIds { get; } = new();
public List<WizardDraft> Upserts { get; } = new();
public int ExpiredDeleted { get; set; }
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
public Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
{
foreach (var d in store.Values)
{
if (d.ChatId == chatId &&
d.MessageThreadId == messageThreadId &&
d.OwnerTelegramId == ownerTelegramId &&
d.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<WizardDraft?>(d);
}
}
return Task.FromResult<WizardDraft?>(null);
}
public Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
// Clone so tests can compare state without aliasing.
Upserts.Add(new WizardDraft
{
Id = draft.Id,
ChatId = draft.ChatId,
MessageThreadId = draft.MessageThreadId,
OwnerTelegramId = draft.OwnerTelegramId,
Step = draft.Step,
PayloadJson = draft.PayloadJson,
DraftMessageId = draft.DraftMessageId,
CreatedAt = draft.CreatedAt,
UpdatedAt = draft.UpdatedAt,
ExpiresAt = draft.ExpiresAt,
});
store[draft.Id] = draft;
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct)
{
DeletedIds.Add(id);
store.Remove(id);
return Task.CompletedTask;
}
public Task<int> DeleteExpiredAsync(CancellationToken ct)
{
var count = ExpiredDeleted;
ExpiredDeleted = 0;
return Task.FromResult(count);
}
}
/// <summary>
/// Records every call the wizard makes against the messenger. Default return
/// values (empty clubs, message-id 1) match what the wizard expects to see
/// in steady state.
/// </summary>
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
{
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
public List<string> AnsweredCallbacks { get; } = new();
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
public Task<long> EditMessageTextAsync(
long chatId,
int? messageThreadId,
long messageId,
string text,
InlineKeyboardMarkup keyboard,
CancellationToken ct)
{
Edits.Add((chatId, messageThreadId, messageId, text));
return Task.FromResult(messageId);
}
public Task<long> SendGroupMessageAsync(
long chatId,
int? messageThreadId,
string text,
InlineKeyboardMarkup keyboard,
CancellationToken ct)
{
Sends.Add((chatId, messageThreadId, text));
return Task.FromResult(99L);
}
public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
{
AnsweredCallbacks.Add(callbackId);
return Task.CompletedTask;
}
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
=> Task.FromResult(Clubs);
}
@@ -15,6 +15,8 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
@@ -8,12 +8,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql"); var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql");
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs"); var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs");
var topicRouting = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs");
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal); Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal); // The wizard-driven CreateSessionHandler threads the existing forum topic
Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal); // (if any) into the draft; the shared creation command inherits it. Topic
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal); // auto-creation and rights handling live in TelegramTopicRouting.
Assert.Contains("MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ExternalThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", topicRouting, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", topicRouting, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal); Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal); Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal); Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);
@@ -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? LastPublicSessionId { get; private set; }
public Guid? LastPublicSessionGroupId { get; private set; } public Guid? LastPublicSessionGroupId { get; private set; }
public bool? LastSessionPublicValue { get; private set; } public bool? LastSessionPublicValue { get; private set; }
public PublicationMode? LastSessionPublicationMode { get; private set; }
public Guid? LastPublicBatchId { get; private set; } public Guid? LastPublicBatchId { get; private set; }
public Guid? LastPublicBatchGroupId { get; private set; } public Guid? LastPublicBatchGroupId { get; private set; }
public bool? LastBatchPublicValue { get; private set; } public bool? LastBatchPublicValue { get; private set; }
public PublicationMode? LastBatchPublicationMode { get; private set; }
public bool RemovePlayerCalled { get; private set; } public bool RemovePlayerCalled { get; private set; }
public Guid? LastRemovedPlayerSessionId { get; private set; } public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; } public Guid? LastRemovedPlayerGroupId { get; private set; }
@@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
{ {
SetSessionPublicCalled = true; SetSessionPublicCalled = true;
LastPublicSessionId = sessionId; LastPublicSessionId = sessionId;
LastPublicSessionGroupId = groupId; LastPublicSessionGroupId = groupId;
LastSessionPublicValue = isPublic; LastSessionPublicationMode = mode;
if (sessionsById.TryGetValue(sessionId, out var session)) if (sessionsById.TryGetValue(sessionId, out var session))
{ {
sessionsById[sessionId] = session with { IsPublic = isPublic }; sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() };
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
{ {
SetBatchPublicCalled = true; SetBatchPublicCalled = true;
LastPublicBatchId = batchId; LastPublicBatchId = batchId;
LastPublicBatchGroupId = groupId; LastPublicBatchGroupId = groupId;
LastBatchPublicValue = isPublic; LastBatchPublicationMode = mode;
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList()) 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; return Task.CompletedTask;
} }
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicClub?>(null); Task.FromResult<WebPublicClub?>(null);
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) => public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicSession?>(null); Task.FromResult<WebPublicSession?>(null);
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) => public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, 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) => public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId)); Task.FromResult(IsOwner(groupId, telegramId));
@@ -1,5 +1,3 @@
using System.Xml.Linq;
namespace GmRelay.Bot.Tests.Web; namespace GmRelay.Bot.Tests.Web;
public sealed class CampaignTemplatesNavigationTests public sealed class CampaignTemplatesNavigationTests
@@ -17,14 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
public async Task NavMenu_ShouldExposeCurrentProjectVersion() public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{ {
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
var props = XDocument.Load(FindRepositoryFile("Directory.Build.props")); Assert.Contains("v3.7.1", navMenu, StringComparison.Ordinal);
var version = props.Root?
.Element("PropertyGroup")?
.Element("Version")?
.Value;
Assert.False(string.IsNullOrWhiteSpace(version));
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
} }
[Fact] [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}'.");
}
}

Some files were not shown because too many files have changed in this diff Show More