Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 | |||
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee | |||
| f796b7d1e4 | |||
| 415c13bf00 | |||
| 85ff3a7faf | |||
| d034d6acb9 | |||
| c4a77d3d73 | |||
| 7cfb1968c0 | |||
| b1bd47f6c1 | |||
| f0952096f3 | |||
| b81d865832 | |||
| 8f0f2ef7e7 | |||
| 71080aeab6 | |||
| a843c8b278 | |||
| 186492a18d | |||
| 2819786f91 | |||
| 8c1bda73ed | |||
| af345ba765 | |||
| 4a04d7d723 | |||
| eeffae659f | |||
| ea567a36ee | |||
| be86a2a08a | |||
| 1b49211085 | |||
| 96a4807002 | |||
| cff4e48b57 | |||
| 384887a862 | |||
| 4d2aef637f | |||
| c45c46abcf | |||
| 2c7495cd8d | |||
| d5fdc19016 | |||
| 10410d758c | |||
| 771ff9be34 | |||
| 29f6f6a827 | |||
| 6951c72f3c | |||
| 22e9859fdf | |||
| 6cb2fbe610 | |||
| 992f71c0e4 | |||
| 21e29564f6 | |||
| 401653a4d1 | |||
| e970e94e00 | |||
| 242ff99a83 | |||
| f2c9f34ab4 | |||
| e5945288ac | |||
| 7d1489445e | |||
| 4af4e52778 | |||
| a20da4b1a0 | |||
| edf40c9a09 | |||
| 1a8161027c | |||
| 85918c1e5d | |||
| ea714480d3 | |||
| 1d62f69ff0 | |||
| d762ecc377 | |||
| a28b75dd5b | |||
| 2b725708ef | |||
| da0a306340 | |||
| f493836b77 | |||
| 6e7a0cb493 | |||
| 76b3ff7ddf | |||
| 536061f63c | |||
| f7a12d14d2 | |||
| 3c1a98bcc4 | |||
| d591e5ed5a | |||
| 5809a470b9 | |||
| ed842d2195 | |||
| a0040ec9fb | |||
| 67b8aafd97 | |||
| ac417731d6 | |||
| 991c7e1965 | |||
| 0d9df29f58 | |||
| d54950698a | |||
| 394bd19b95 | |||
| b52d4000b4 |
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Имя Docker volume для резервных копий БД
|
||||
BACKUP_VOLUME_NAME=game_pgbackups
|
||||
|
||||
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.5.0
|
||||
VERSION: 3.9.6
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -72,7 +72,27 @@ jobs:
|
||||
steps:
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
# upstream install.sh. Rationale:
|
||||
# 1. install.sh resolves the positional tag against the
|
||||
# GitHub releases API; when a release is unpublished or
|
||||
# yanked, the script fails with
|
||||
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||
# even when the release once existed. We hit this with
|
||||
# v0.71.0.
|
||||
# 2. Docker Hub tags are content-addressed and rarely
|
||||
# removed, so a pinned image tag is much more stable.
|
||||
# 3. The image is multi-arch (linux/amd64, linux/arm64,
|
||||
# linux/ppc64le, linux/s390x) so the same tag works on
|
||||
# the GitHub-hosted runner and on the ARM64 Pi runner.
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.70.0"
|
||||
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||
docker rm trivy-tmp >/dev/null
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
|
||||
@@ -47,7 +47,19 @@ jobs:
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
# upstream install.sh. Rationale (see deploy.yml for the long
|
||||
# version): the GitHub release tag we pinned (v0.71.0) was
|
||||
# unpublished, and install.sh fails hard on missing tags.
|
||||
# Docker Hub images are content-addressed and rarely removed,
|
||||
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.70.0"
|
||||
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||
docker rm trivy-tmp >/dev/null
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Trivy filesystem security scan
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.5.0</Version>
|
||||
<Version>3.9.6</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v3.5.0`.
|
||||
**Текущая версия:** `v3.6.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
|
||||
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
|
||||
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
@@ -126,6 +129,32 @@ docker compose up -d
|
||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
||||
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
||||
|
||||
## 📚 Портфолио завершённых приключений
|
||||
|
||||
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
|
||||
|
||||
### Возможности
|
||||
|
||||
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
|
||||
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
|
||||
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
|
||||
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
|
||||
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
|
||||
|
||||
### Хранение обложек
|
||||
|
||||
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
В `.env` можно переопределить имя volume:
|
||||
|
||||
```env
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
```
|
||||
|
||||
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
|
||||
|
||||
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
|
||||
|
||||
## 💾 Backup и восстановление
|
||||
|
||||
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
|
||||
|
||||
+81
-5
@@ -1,8 +1,84 @@
|
||||
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
|
||||
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
|
||||
|
||||
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync` → `PlatformNotSupportedException` → `GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
|
||||
|
||||
### 🩹 Что починено
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync` — `new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
|
||||
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
|
||||
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
|
||||
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
|
||||
|
||||
### ⚠️ Известные ограничения
|
||||
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
|
||||
|
||||
### 🧪 Тесты
|
||||
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
|
||||
|
||||
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
||||
|
||||
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
|
||||
|
||||
### 🩹 Что починено
|
||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
|
||||
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — `CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs` — `DateTimeOffset.UtcNow` → `DateTime.UtcNow` в новых драфтах.
|
||||
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
|
||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
|
||||
|
||||
### ⚠️ Известные ограничения
|
||||
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
|
||||
|
||||
### 🧪 Тесты
|
||||
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
|
||||
|
||||
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
|
||||
|
||||
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
|
||||
|
||||
### 🧩 Что вошло в релиз
|
||||
|
||||
**Платформо-нейтральный рефакторинг (GmRelay.Shared)**
|
||||
- `Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — стейт-машина визарда (один источник правды для обеих платформ)
|
||||
- `Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` — контракт мессенджера (edit/send/answer/getOwnerClubs)
|
||||
- `Features/Sessions/CreateSession/Wizard/WizardInteraction.cs` — запись взаимодействия (OwnerId, Text, CallbackPayload, PhotoFileId, PhotoUrl, InteractionId)
|
||||
- `Features/Sessions/CreateSession/Wizard/WizardAction.cs`, `WizardKeyboard.cs`, `WizardStepLimits.cs` — модель кнопок и лимитов
|
||||
- `Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — добавлено поле `Platform`
|
||||
- `Migrations/V032__add_wizard_drafts_platform.sql` — `ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
|
||||
|
||||
**Discord-адаптер (GmRelay.DiscordBot)**
|
||||
- `Features/Sessions/Wizard/DiscordWizardCommand.cs` — slash-команда `/newsession-wizard` с проверкой owner/co-GM через `DiscordPermissionLookup`
|
||||
- `Features/Sessions/Wizard/DiscordWizardStep.cs` — рендер 15 шагов в NetCord embed + buttons/StringSelectMenu/modals
|
||||
- `Features/Sessions/Wizard/DiscordWizardMessenger.cs` — реализация `IWizardMessenger` через NetCord REST (edit с fallback на re-send при 401/403/404)
|
||||
- `Features/Sessions/Wizard/DiscordWizardSubmitter.cs` — финализация с 3-retry циклом
|
||||
- `Features/Sessions/Wizard/DiscordWizardContextStore.cs` — in-memory кэш контекста (guild/channel/messageId) для 15-минутного interaction token
|
||||
- `Features/Sessions/Wizard/DiscordWizardInteractionModule.cs` — inbound handlers: 3 NetCord `ComponentInteractionModule<TContext>` (button/StringMenu/Modal) + `WizardInteractionDispatcher`
|
||||
- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers`
|
||||
- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions<TInteraction, TContext>`
|
||||
|
||||
**Тесты**
|
||||
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*` — обновлены под новый контракт
|
||||
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` — 12 source-level smoke-тестов на структуру interaction module
|
||||
|
||||
### 🗺 Что это даёт
|
||||
- Мастера (GM) могут пошагово создавать игры и пулы слотов прямо в Discord через slash-команду, кнопки, выпадающие меню и модальные окна.
|
||||
- UX адаптирован под Discord (нативные components), а не скопирован из Telegram.
|
||||
- Общая стейт-машина и валидация: Telegram и Discord визарды развиваются синхронно, баги фиксятся в одном месте.
|
||||
- PickClub-шаг использует реальный SQL-запрос к `club_memberships` с фильтром по роли Owner/CoGm.
|
||||
|
||||
### 📦 Версия и деплой
|
||||
- Версия обновлена до 3.9.0 (`NavMenu.razor`, `.gitea/workflows/deploy.yml`)
|
||||
- Docker-образы будут тегированы `3.9.0` при пуше в `main`
|
||||
- Миграция V032 применяется автоматически на старте Bot
|
||||
|
||||
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
|
||||
|
||||
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
|
||||
|
||||
## 🧩 Что вошло в релиз
|
||||
### 🧩 Что вошло в релиз
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
|
||||
@@ -13,11 +89,11 @@
|
||||
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
|
||||
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
|
||||
|
||||
## 🗺 Что это даёт
|
||||
### 🗺 Что это даёт
|
||||
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
|
||||
- Участники сервера видят расписание через /listsessions.
|
||||
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
|
||||
|
||||
## 📦 Версия и деплой
|
||||
### 📦 Версия и деплой
|
||||
- версия обновлена до 2.4.0
|
||||
- Docker-образы используют тег 2.4.0
|
||||
- Docker-образы используют тег 2.4.0
|
||||
|
||||
+11
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.6
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,11 +67,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.6
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
||||
@@ -84,11 +86,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.6
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bot:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||
@@ -97,10 +101,12 @@ services:
|
||||
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
||||
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
||||
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
||||
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||
ports:
|
||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- web_keys:/app/dataprotection-keys
|
||||
- portfolio_covers:/app/portfolio-covers
|
||||
networks:
|
||||
- gmrelay
|
||||
healthcheck:
|
||||
@@ -116,6 +122,8 @@ volumes:
|
||||
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||
pgbackups:
|
||||
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
||||
portfolio_covers:
|
||||
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||
|
||||
networks:
|
||||
gmrelay:
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# Discord wizard adapter — issue #112
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the Discord side of the platform-neutral game/pool creation
|
||||
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
|
||||
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
|
||||
handler module turn the `/newsession-wizard` slash command into a
|
||||
fully clickable step-by-step wizard with button, StringSelectMenu, and
|
||||
modal handlers. The shared `GameCreationWizard` state machine in
|
||||
`GmRelay.Shared` is the single source of truth — the Discord adapter
|
||||
only translates between NetCord's interaction types and the
|
||||
platform-neutral `WizardInteraction` record.
|
||||
|
||||
## Files
|
||||
|
||||
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
|
||||
|
||||
- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface +
|
||||
thread-safe in-memory store. Keyed by `draft.Id`. Holds the
|
||||
`(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
|
||||
needs to re-send a draft after a 15-minute interaction token
|
||||
expires.
|
||||
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
|
||||
an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
|
||||
`ActionRow` buttons with `StringMenu` select menus) and exposes
|
||||
`BuildModal` for the 8 modal-collecting steps.
|
||||
- `DiscordWizardMessenger.cs` — `IWizardMessenger` impl. Uses
|
||||
`RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
|
||||
back to re-send on 401/403/404). Toast replies are stashed in the
|
||||
existing `DiscordInteractionReplyCache`.
|
||||
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
|
||||
shared `CreateSessionCommand` and calls `CreateSessionHandler`;
|
||||
on success edits the draft to "✅ Создано: N сессий", on failure
|
||||
shows retry/cancel buttons.
|
||||
- `DiscordWizardCommand.cs` — `/newsession-wizard` slash command.
|
||||
Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
|
||||
- `DiscordPermissionLookup.cs` — small DB helper that loads
|
||||
`group_managers` rows for a guild.
|
||||
- `DiscordWizardInteractionModule.cs` — **inbound handlers** (this
|
||||
commit). Three NetCord `ComponentInteractionModule<TContext>`
|
||||
shells (button / StringSelectMenu / modal) share one
|
||||
`WizardInteractionDispatcher` that:
|
||||
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
|
||||
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
|
||||
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
|
||||
2. loads the active draft via
|
||||
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
|
||||
3. routes the callback through the shared
|
||||
`GameCreationWizard.HandleInteractionAsync` (or
|
||||
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
|
||||
4. opens a modal popup when the new step needs text input,
|
||||
otherwise acks with a deferred message so Discord doesn't show
|
||||
"Application did not respond".
|
||||
|
||||
DI / tests:
|
||||
|
||||
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
|
||||
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
|
||||
`GameCreationWizard`, `DiscordWizardSubmitter`,
|
||||
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
|
||||
3 `AddComponentInteractions<TInteraction, TContext>` calls
|
||||
(Button, StringMenu, Modal).
|
||||
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||
— 12 source-level structural smoke tests: handler classes exist,
|
||||
all 3 derive from `ComponentInteractionModule<TContext>`, all 3
|
||||
register `[ComponentInteraction("wizard")]`, the dispatcher
|
||||
exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
|
||||
`HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
|
||||
`cancel` / `create` / `resume`) are routed, the dispatcher
|
||||
invokes `GameCreationWizard.HandleInteractionAsync` and
|
||||
`DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
|
||||
registers all 3 `AddComponentInteractions` and all 4 module
|
||||
classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
|
||||
walks `Components[0] → TextInput → .Value`, string menu reads
|
||||
`SelectedValues[0]`.
|
||||
|
||||
## Custom-id wire format
|
||||
|
||||
| Interaction | Custom-id | Handler |
|
||||
|------------------------|------------------------------------------|-------------------------------------------------|
|
||||
| Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
|
||||
| Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
|
||||
| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
|
||||
| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
|
||||
| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
|
||||
| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
|
||||
| StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
|
||||
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
|
||||
|
||||
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
|
||||
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
|
||||
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
|
||||
parse the rest.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- ✅ `dotnet build` решения — 0 warnings, 0 errors
|
||||
- ✅ `dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
|
||||
skipped). 12 source-level smoke tests cover the interaction module.
|
||||
- ✅ `dotnet format --verify-no-changes` — clean
|
||||
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
|
||||
`f095209`).
|
||||
|
||||
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||
|
||||
## Open questions
|
||||
|
||||
- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.**
|
||||
`WizardClubLookup.LoadClubsAsync` now queries `group_managers`
|
||||
directly via injected `NpgsqlDataSource` with the same
|
||||
`Owner | CoGm` role filter the messenger uses. The dispatcher
|
||||
reads the owner's real club list and renders them in the
|
||||
StringSelectMenu. Build green, 12 source-level smoke tests still
|
||||
pass.
|
||||
- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.**
|
||||
The dispatcher now runs the wizard first (which edits the draft
|
||||
embed), then sends the response as either
|
||||
`InteractionCallback.Modal(modalProperties)` (when the new step
|
||||
needs text input) or `InteractionCallback.DeferredMessage()`
|
||||
(otherwise). NetCord locks the response type after the first
|
||||
`SendResponseAsync` call, so the fix is NOT to call
|
||||
`DeferredMessage` upfront.
|
||||
- **Modal handler's free-text mapping is a hack.** Modal steps like
|
||||
`SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
|
||||
are mapped to the canonical wizard step (`System`, `Duration`,
|
||||
`PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
|
||||
because the wizard's `ApplyText` dispatches on the canonical step
|
||||
name, but a future refactor of `ApplyText` to know about the
|
||||
free-text step names would break this. The clean fix is to add
|
||||
dedicated "free text" steps to `WizardStepNames`.
|
||||
- **Resume:continue is a re-render, not a true resume.** The wizard
|
||||
has no special resume case; clicking "▶️ Продолжить" just re-emits
|
||||
the current step's embed. This is fine for the user (the embed is
|
||||
identical to the last one they saw before the click), but the
|
||||
underlying state isn't really "continued" — if the wizard's cleanup
|
||||
service expired the draft between the slash command and the
|
||||
click, the user gets a re-render of an empty step.
|
||||
- **One-draft-per-owner invariant.** The wizard's "one active draft
|
||||
per owner" rule means a single Discord user can't run two wizard
|
||||
sessions in parallel. Acceptable for now, but the wizard's state
|
||||
machine doesn't enforce this — only the dispatcher does, via
|
||||
`GetActiveAsync("Discord", ownerId)`.
|
||||
|
||||
## Final commit history on `feat/issue-112-wizard-refactor`
|
||||
|
||||
- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types)
|
||||
- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command)
|
||||
- `f095209` — Task 2: interaction module + modal popup fix
|
||||
- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup
|
||||
|
||||
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||
@@ -8,19 +8,20 @@ C4Context
|
||||
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles without private player data")
|
||||
Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile pages, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile/portfolio pages, and shared scheduling logic")
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
|
||||
|
||||
Rel(gm, telegram, "Creates and manages sessions")
|
||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||
Rel(player, telegram, "Uses inline buttons")
|
||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||
Rel(visitor, gmrelay, "Views public club, session, and GM profile pages")
|
||||
Rel(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
|
||||
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -41,19 +42,21 @@ C4Container
|
||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile pages, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile/portfolio pages, portfolio review submission and moderation, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
System_Ext(discord, "Discord Gateway and REST API")
|
||||
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
|
||||
|
||||
Rel(gm, telegram, "Commands")
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule and sanitized GM profile pages")
|
||||
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
|
||||
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
@@ -64,6 +67,7 @@ C4Container
|
||||
Rel(bot, db, "Npgsql + Dapper.AOT")
|
||||
Rel(discordBot, db, "Npgsql + Dapper")
|
||||
Rel(web, db, "Npgsql + Dapper")
|
||||
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
|
||||
```
|
||||
|
||||
## Level 3: Component - Session Interactions
|
||||
@@ -125,3 +129,57 @@ C4Component
|
||||
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
|
||||
Rel(healthCheck, discord, "HTTP /health")
|
||||
```
|
||||
|
||||
## Level 3: Component - Completed-Adventure Portfolios
|
||||
|
||||
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Completed-Adventure Portfolio Subsystem
|
||||
|
||||
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
|
||||
Person(player, "Player", "Submits one moderated review per completed adventure")
|
||||
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
|
||||
|
||||
Container_Boundary(web, "GmRelay.Web") {
|
||||
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
|
||||
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
|
||||
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
|
||||
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
|
||||
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
|
||||
}
|
||||
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
|
||||
|
||||
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
|
||||
Rel(player, pages, "Submits review")
|
||||
Rel(visitor, pages, "Reads public portfolio and approved reviews")
|
||||
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
|
||||
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
|
||||
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
|
||||
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
|
||||
Rel(authorized, covers, "Save, read, delete cover files")
|
||||
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
|
||||
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
|
||||
Rel(covers, coversVolume, "Filesystem reads/writes")
|
||||
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||
```
|
||||
|
||||
### Portfolio tables (PostgreSQL)
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
|
||||
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
|
||||
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
|
||||
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
|
||||
|
||||
### Cover storage boundary
|
||||
|
||||
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
|
||||
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
|
||||
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
|
||||
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Detailed code review plan for the Discord wizard feature branch.
|
||||
|
||||
Read this file FIRST. It has the full review scope. The original prompt
|
||||
in the spawn was truncated due to Windows CLI limits; this file is the
|
||||
canonical spec.
|
||||
"""
|
||||
|
||||
# Branch to review
|
||||
BRANCH = "feat/issue-112-wizard-refactor"
|
||||
BASE = "origin/main"
|
||||
|
||||
# Files of interest
|
||||
SHARED = "src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/*"
|
||||
BOT_WIZARD = "src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/*"
|
||||
BOT_CREATE = "src/GmRelay.Bot/Features/Sessions/CreateSession/*"
|
||||
MIGRATION = "src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql"
|
||||
DISCORD = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/*"
|
||||
PROG_CS = "src/GmRelay.DiscordBot/Program.cs"
|
||||
TESTS_WIZ = "tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*"
|
||||
TESTS_SMOKE = "tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs"
|
||||
DELIVERABLE = "deliverable.md"
|
||||
|
||||
REVIEW_FOCUS = [
|
||||
"Architecture: Shared/Bot/DiscordBot separation; no Telegram.Bot in Shared; no NetCord in Shared; single state machine source.",
|
||||
"Security: owner/co-GM checks everywhere; NRE on null Context.User; SQL injection; connection strings with passwords.",
|
||||
"Correctness: AOT-safety (no reflection, no dynamic); off-by-one in customId parsers; CancellationToken/Services.",
|
||||
"Style: naming consistent; Async/await by convention; logging at right levels.",
|
||||
"Tests: smoke tests are string-matching — where would real tests be useful?",
|
||||
"Migration safety: V032 DEFAULT value, will it fail on existing rows?",
|
||||
"Documentation: deliverable.md updated, open questions listed?",
|
||||
]
|
||||
|
||||
OUTPUT_FORMAT = """\
|
||||
## VERDICT: APPROVE / REQUEST_CHANGES / COMMENT
|
||||
|
||||
## Critical findings
|
||||
(file:line — what's wrong — how to fix)
|
||||
|
||||
## Important findings
|
||||
(file:line — what's wrong)
|
||||
|
||||
## Nits
|
||||
(quick observations)
|
||||
|
||||
## Summary
|
||||
(1-2 sentences)
|
||||
"""
|
||||
|
||||
COMMANDS_HINT = """\
|
||||
git fetch origin
|
||||
git diff origin/main..feat/issue-112-wizard-refactor --stat
|
||||
git diff origin/main..feat/issue-112-wizard-refactor
|
||||
dotnet build && dotnet test
|
||||
"""
|
||||
@@ -0,0 +1,362 @@
|
||||
# Code review — feat/issue-112-wizard-refactor (issue #112)
|
||||
|
||||
**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb)
|
||||
**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`)
|
||||
**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3`
|
||||
**Build:** ✅ `dotnet build GM-Relay.slnx` — 0 warnings, 0 errors
|
||||
**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing
|
||||
`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
||||
(uncommitted release work in working tree, not part of this branch).
|
||||
|
||||
## VERDICT: REQUEST_CHANGES
|
||||
|
||||
The branch is **NOT shippable in its current state.** Every choice button
|
||||
and every "Другое…" button in the wizard is silently broken at runtime
|
||||
due to a wire-format mismatch between the renderer and the dispatcher.
|
||||
A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать",
|
||||
or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of
|
||||
the wizard advancing. The 12 source-level smoke tests don't catch this
|
||||
because they only check string presence in source code, not the actual
|
||||
button-click → dispatch flow.
|
||||
|
||||
The architecture is otherwise sound: no Telegram.Bot/NetCord leak into
|
||||
Shared, single state-machine source, all DI wired, AOT-safe, parameterized
|
||||
SQL, owner/co-GM permission check with null-safety, SecretRedactor on the
|
||||
connection string. The fix is small and surgical.
|
||||
|
||||
---
|
||||
|
||||
## Critical findings
|
||||
|
||||
### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end
|
||||
|
||||
**Files:**
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80`
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226`
|
||||
|
||||
**What's wrong.** The dispatcher's button handler matches `parts[1]`
|
||||
against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and
|
||||
falls through to "default → Неизвестная кнопка" for anything else. The
|
||||
dispatcher's own documentation and the deliverable's wire-format table
|
||||
both agree the canonical choice-button format is
|
||||
`wizard:btn:choice:<step>:<value>`. But `ButtonCustomId` emits
|
||||
`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is
|
||||
missing**. So clicking "D&D 5e" on the System step produces
|
||||
`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]`
|
||||
prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"` →
|
||||
`parts = ["btn", "System", "Dnd5e"]` → `parts[1] = "System"` → default
|
||||
branch → "⚠️ Неизвестная кнопка".
|
||||
|
||||
The same bug hits:
|
||||
- `RenderType` — "Одну игру" / "Пул игр" buttons (emits
|
||||
`wizard:btn:Type:single`, `wizard:btn:Type:pool`)
|
||||
- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.)
|
||||
**and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`)
|
||||
- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and
|
||||
"⏭ Пропустить"
|
||||
- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist"
|
||||
(emits `wizard:btn:Capacity:waitlist:on` etc.)
|
||||
- `RenderPublish` — "Опубликовать" / "Только в чате"
|
||||
- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью"
|
||||
- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel")
|
||||
|
||||
Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons
|
||||
are unaffected by *this specific* bug (see C-2 for modal buttons).
|
||||
|
||||
The smoke test `Dispatcher_ShouldParseAllWizardActionKinds`
|
||||
(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`)
|
||||
checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`,
|
||||
`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't
|
||||
check the renderer's output, so the bug is invisible to the test suite.
|
||||
The same file's comment at line 69 documents the *expected* format as
|
||||
`"btn:choice:Type:single"` — which would be the correct fix.
|
||||
|
||||
**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`:
|
||||
|
||||
```csharp
|
||||
public static string ButtonCustomId(string step, string value) =>
|
||||
$"wizard:btn:choice:{step}:{value}";
|
||||
```
|
||||
|
||||
This brings the renderer into alignment with the dispatcher's switch
|
||||
case `"choice"` (line 209) and with the deliverable's table at line 83.
|
||||
Re-verify with a manual click-through of every button on every step, or
|
||||
add a parser-side test (see I-3 below).
|
||||
|
||||
### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314`
|
||||
**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226`
|
||||
|
||||
**What's wrong.** The renderer emits modal triggers as
|
||||
`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System
|
||||
step), `wizard:btn:modal:DurationFreeText` (Duration step), and
|
||||
`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step).
|
||||
The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"`
|
||||
but not `"modal"`. The user's click on "Другое…" hits the default branch
|
||||
and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard
|
||||
doesn't advance. The open question in `deliverable.md:125-132` ("Modal
|
||||
handler's free-text mapping is a hack") implicitly assumes these buttons
|
||||
*work* in production, so the design intent is clear but the implementation
|
||||
didn't deliver it.
|
||||
|
||||
**How to fix.** Add a `"modal"` case in the dispatcher's switch (between
|
||||
`"create"` and the existing branches, mirroring the "create" / "resume"
|
||||
special-case pattern):
|
||||
|
||||
```csharp
|
||||
if (parts[1] == "modal" && parts.Length >= 3)
|
||||
{
|
||||
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
|
||||
if (modal is not null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||
}
|
||||
else
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Модал недоступен");
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
This bypasses the wizard's state machine entirely (the user's intent is
|
||||
"open a modal for free-text input", not "advance the wizard"). When the
|
||||
user submits the modal, `HandleModalAsync` will run, which already knows
|
||||
how to map `SystemFreeText` → `WizardStepNames.System` (line 453-462).
|
||||
Add a click-through test for at least one of the three steps.
|
||||
|
||||
### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104`
|
||||
|
||||
**What's wrong.** On submit failure the submitter edits the draft
|
||||
message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."`
|
||||
and ships it to the user-visible draft embed. The exception originates
|
||||
in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres
|
||||
exception messages routinely include the constraint name, the conflicting
|
||||
key value, and sometimes the full SQL text. Even the connection-string
|
||||
DSN could leak if an `NpgsqlException` wraps a connection failure. A
|
||||
malicious user who can submit many sessions can probe DB schema and
|
||||
state by reading the error strings.
|
||||
|
||||
**How to fix.** Log the full `ex` to the server-side log (already done
|
||||
on line 86) but show the user a generic error:
|
||||
|
||||
```csharp
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. "
|
||||
+ "Попробуйте повторить или обратитесь к администратору.",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
```
|
||||
|
||||
If you want to preserve a per-error recovery hint (e.g. "Duplicate
|
||||
title — pick a different name"), map known exception types to localized
|
||||
strings; never embed the raw `ex.Message`.
|
||||
|
||||
---
|
||||
|
||||
## Important findings
|
||||
|
||||
### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87`
|
||||
|
||||
The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)`
|
||||
on every `/newsession-wizard` invocation. This is a 3-table join that
|
||||
scales linearly with the number of clubs the user manages. With a 24-hour
|
||||
draft lifetime and a single draft per owner, the same query repeats
|
||||
frequently. Not critical for the v3.8.0 release, but a 30-second in-memory
|
||||
cache would cut DB load noticeably during heavy wizard use. Same query
|
||||
shape lives in `DiscordNewSessionHandler` already (per the file comment),
|
||||
so a shared cache would benefit both.
|
||||
|
||||
### I-2. `_skip` sentinel bypasses the wizard's own validation
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207`
|
||||
**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290`
|
||||
|
||||
`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it
|
||||
without further checks. The renderer emits `wizard:btn:System:_skip`.
|
||||
This is correct *if* the choice-button wire format gets fixed (C-1); the
|
||||
"`_skip`" string is hard-coded in the wizard and the renderer uses the
|
||||
same constant. But there's no central constant — both files have their
|
||||
own copies of the magic string. A future refactor that renames the
|
||||
sentinel in one place will silently break the other. Suggest
|
||||
`public const string SkipSentinel = "_skip"` on a shared class.
|
||||
|
||||
### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter
|
||||
|
||||
**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||
|
||||
All 12 smoke tests in this file are `Assert.Contains` against the
|
||||
source text. They would all pass against a file full of `// choice`
|
||||
comments and dead code. The test class header at line 8-17 acknowledges
|
||||
this ("smoke gate"), and the broader Wizard test suite
|
||||
(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does
|
||||
exercise the platform-neutral state machine — but the *adapter* (the
|
||||
mapping from `ButtonInteractionContext` → `_wizard.HandleInteractionAsync`)
|
||||
has zero behavioural coverage. C-1 and C-2 both bypass the entire
|
||||
smoke-test surface.
|
||||
|
||||
Minimum bar to add: a parser-roundtrip test that takes the renderer's
|
||||
output for each `RenderX()` step and feeds it through the dispatcher's
|
||||
button handler to verify it doesn't fall into the default branch. Even
|
||||
a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics
|
||||
the dispatcher's `args.Split(':', 4)` parser) would catch both bugs.
|
||||
Cost: ~50 lines; payoff: catches the entire class of "renderer and
|
||||
dispatcher disagree on the wire format" regressions.
|
||||
|
||||
### I-4. `AddComponentInteractions<TInteraction, TContext>` is called for Modal but the renderer relies on `Label → TextInput` layout
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444`
|
||||
**Read against:** `DiscordWizardInteractionModule.cs:440-451`
|
||||
|
||||
`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's
|
||||
`IModalComponentProperties` API requires labels). The dispatcher reads
|
||||
`Components[0]` and assumes it is a `Label` (line 446). This is
|
||||
consistent *today*, but if a future step needs two inputs in one
|
||||
modal, the extraction logic needs to walk all components, not just
|
||||
`[0]`. Document the constraint on the dispatcher's
|
||||
`ExtractModalText` method ("current contract: exactly one Label,
|
||||
one TextInput") and the renderer's `BuildModal` ("emits one
|
||||
Label+TextInput, no exceptions").
|
||||
|
||||
### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89`
|
||||
|
||||
`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before*
|
||||
the `if (payload.RetryCount >= MaxRetries)` check. The counter is
|
||||
serialized into `draft.PayloadJson` and re-loaded on the next click, so
|
||||
the bound is correct across bot restarts. However, the in-memory
|
||||
`draft` object is shared with the dispatcher's `_wizard` after
|
||||
`HandleInteractionAsync` returns, and a future refactor that pulls the
|
||||
payload from the DB instead of the in-memory copy could see a stale
|
||||
count. Document the invariant: "RetryCount is read from the in-memory
|
||||
payload after this line; do not re-load from DB before the comparison."
|
||||
|
||||
### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons
|
||||
|
||||
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209`
|
||||
|
||||
`BuildResumeRow` emits three buttons:
|
||||
- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works)
|
||||
- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works)
|
||||
- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`)
|
||||
|
||||
The cancel button relies on the dispatcher's `parts[1] == "cancel"`
|
||||
match. With C-1 fixed, this still works because cancel is in the
|
||||
switch, not the new "choice" path. But the `ButtonCustomId` signature
|
||||
will change semantics after C-1: it will become
|
||||
`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing
|
||||
`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`,
|
||||
which the dispatcher's switch will not match (no `"choice"` in the
|
||||
parts). Fix C-1 must update `BuildResumeRow` to emit
|
||||
`wizard:btn:cancel:1` directly (not via `ButtonCustomId`).
|
||||
|
||||
---
|
||||
|
||||
## Nits
|
||||
|
||||
- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal
|
||||
handlers split args with `Split(':', 2)` (max 2 parts), while the
|
||||
button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The
|
||||
net effect is correct (select has 2 segments, modal has 2, button has
|
||||
2–4), but a comment explaining the max-count rationale would help.
|
||||
- `DiscordWizardStep.cs:74` — `throw new InvalidOperationException` on
|
||||
unknown step is fine for now, but a future maintainer adding a step
|
||||
to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness
|
||||
check (e.g. a private static assert in tests) would catch this at
|
||||
build time.
|
||||
- `DiscordPermissionLookup.cs:28-30` — `g.platform = 'Discord'` is
|
||||
hard-coded in the SQL. A `g.platform = @Platform` parameter would
|
||||
mirror the dispatcher's parameterized style and make the helper
|
||||
reusable for any future platform. Not blocking.
|
||||
- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside
|
||||
the slash command costs an extra round-trip. The
|
||||
`resolvedPermissions` from the interaction already includes
|
||||
`Administrator`; only the "guild owner" case needs the REST call.
|
||||
Consider short-circuiting when `(resolvedPermissions & Administrator)
|
||||
!= 0`.
|
||||
- `DiscordWizardMessenger.cs:188-192` — hard-coded
|
||||
`new Color(0x5865F2)` (Discord blurple). Extracting to a const
|
||||
`WizardEmbedColor` would let the Web/Telegram versions use the same
|
||||
brand color if they ever render wizards.
|
||||
|
||||
---
|
||||
|
||||
## Migration V032 sanity check
|
||||
|
||||
**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
|
||||
|
||||
- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
|
||||
— DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on
|
||||
existing rows.
|
||||
- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the
|
||||
`chat_id`, `message_thread_id`, `draft_message_id`, and renamed
|
||||
`owner_id` columns. All conversions are lossless
|
||||
(BIGINT → decimal-string, INT → decimal-string). Safe.
|
||||
- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the
|
||||
rename happens mid-migration. Any DML hitting the table
|
||||
concurrently that uses the old name will fail. For a bot that
|
||||
processes both Telegram and Discord traffic, this is a brief
|
||||
exclusivity lock. Consider splitting into two migrations
|
||||
(rename + new index, then type change) so each lock is shorter.
|
||||
Not blocking for v3.8.0, but document the brief lock window.
|
||||
|
||||
No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The
|
||||
deliverable's "Will this fail on existing rows?" question gets a
|
||||
"no, but plan a maintenance window" answer.
|
||||
|
||||
---
|
||||
|
||||
## Architecture sanity (re-confirmed)
|
||||
|
||||
- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are
|
||||
in doc comments warning the developer not to add them. csproj has
|
||||
no Telegram.Bot or NetCord package references. `GameCreationWizard`,
|
||||
`IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`,
|
||||
`WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly
|
||||
one place (Shared). ✓
|
||||
- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services
|
||||
registered as singleton. All 3 `AddComponentInteractions<...>`
|
||||
calls present (Button, StringMenu, Modal). All 4 module/dispatcher
|
||||
classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`)
|
||||
registered. ✓
|
||||
- AOT-safety: no `System.Reflection`, no `dynamic`, no
|
||||
`Activator.CreateInstance`, no `Type.GetType` in the new Discord
|
||||
or Shared code. ✓
|
||||
- `DiscordPermissionLookup.cs:23-31` and
|
||||
`DiscordWizardMessenger.cs:154-165` and the inline
|
||||
`WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519`
|
||||
all use parameterized queries (`@GuildId`, `@Platform`,
|
||||
`@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓
|
||||
- `Program.cs:54` — `SecretRedactor.RedactConnectionString` on the
|
||||
startup log. ✓
|
||||
- `DiscordWizardCommand.cs:51-94` — DM invocations rejected
|
||||
(`GuildId` null check), channel null-checked, member type-checked
|
||||
via `as GuildInteractionUser`, owner/admin/DB-manager permission
|
||||
check via `DiscordPermissionChecker.CanManageSchedule`. No NRE
|
||||
on `Context.User`. ✓
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Strong foundation: the platform-neutral refactor is well-executed, the
|
||||
state machine has solid test coverage, the Discord adapter's DI graph
|
||||
is clean, and security primitives (parameterized SQL, permission check,
|
||||
secret redaction) are in place. But the Discord adapter's runtime path
|
||||
is untested, and a single oversight in the renderer's button custom-id
|
||||
format (missing the `choice:` segment) breaks every choice button in
|
||||
the wizard at click time. The "Другое… ✏️" modal triggers are also
|
||||
unrouted in the dispatcher, leaving the free-text input path
|
||||
unreachable. The 3-attempt finalize loop works but leaks `ex.Message`
|
||||
to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural
|
||||
test of the adapter), and re-running the manual click-through checklist
|
||||
(System → Duration → DateTime → Capacity → Visibility → Publish →
|
||||
Confirm for Single; full pool flow), this branch is ready to merge.
|
||||
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.
|
||||
@@ -2,18 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithPgAdmin()
|
||||
.AddDatabase("gmrelay-db");
|
||||
.AddDatabase("gmrelaydb");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
|
||||
.WithHttpHealthCheck("/health", endpointName: "health");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Web>("web")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
|
||||
@@ -1,194 +1,260 @@
|
||||
using Dapper;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot.Types;
|
||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<CreateSessionHandler> logger)
|
||||
/// <summary>
|
||||
/// Telegram-side entry point for the wizard-driven session creation
|
||||
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
|
||||
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
|
||||
/// platform glue (mapping <c>Message</c> to draft fields, rendering
|
||||
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
|
||||
/// </summary>
|
||||
public sealed class CreateSessionHandler
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken ct)
|
||||
private const int MaxRetries = 3;
|
||||
private const string PlatformName = "Telegram";
|
||||
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly SharedCreateSessionHandler _shared;
|
||||
private readonly IWizardMessenger _messenger;
|
||||
private readonly ILogger<CreateSessionHandler> _log;
|
||||
|
||||
public CreateSessionHandler(
|
||||
IWizardDraftRepository drafts,
|
||||
SharedCreateSessionHandler shared,
|
||||
IWizardMessenger 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 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 ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||
ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||
ct);
|
||||
}
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
|
||||
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
|
||||
OwnerId = ownerId,
|
||||
Platform = PlatformName,
|
||||
Step = WizardStepNames.Type,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
|
||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||
draft.DraftMessageId = msgId;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
return draft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resume an existing draft — returns the draft row so the caller
|
||||
/// can re-render the resume/reset menu.
|
||||
/// </summary>
|
||||
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
return _drafts.GetActiveAsync(PlatformName, ownerId, 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(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
||||
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);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
||||
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;
|
||||
var commands = BuildCommands(draft, payload);
|
||||
try
|
||||
{
|
||||
result = await sharedHandler.HandleAsync(command, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
"💥 Произошла ошибка базы данных при создании сессии.",
|
||||
ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
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),
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
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.EditDraftMessageAsync(
|
||||
draft,
|
||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
return;
|
||||
}
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||
RetryCancelActions(),
|
||||
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?
|
||||
.OrderByDescending(photo => photo.FileSize ?? 0)
|
||||
.ThenByDescending(photo => photo.Width * photo.Height)
|
||||
.FirstOrDefault()
|
||||
?.FileId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
|
||||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||
{
|
||||
return attachedPhotoFileId;
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||
MaxPlayersForPool(pool),
|
||||
isOneShot: false),
|
||||
};
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
draft.OwnerId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalUsername: null);
|
||||
var group = new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
draft.ChatId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalChannelId: null,
|
||||
ExternalThreadId: draft.MessageThreadId);
|
||||
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 IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||
{
|
||||
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
|
||||
/// Translates the platform-neutral wizard contracts into the
|
||||
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
|
||||
/// (message editing, callback ack, group lookup) lives behind the
|
||||
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
|
||||
/// </summary>
|
||||
public sealed class TelegramWizardMessenger(
|
||||
ITelegramBotClient bot,
|
||||
NpgsqlDataSource dataSource) : IWizardMessenger
|
||||
{
|
||||
public async Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||
}
|
||||
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
|
||||
{
|
||||
// No draft message recorded yet — fall back to sending a new one.
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
var msg = await bot.EditMessageText(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
text: text,
|
||||
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||
cancellationToken: ct);
|
||||
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public async Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||
}
|
||||
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
|
||||
? parsedThread
|
||||
: null;
|
||||
|
||||
var msg = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: text,
|
||||
messageThreadId: threadId,
|
||||
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||
cancellationToken: ct);
|
||||
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||
{
|
||||
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, 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.
|
||||
//
|
||||
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
|
||||
// (sql, object?) extension overload — not the (CommandDefinition) overload.
|
||||
// The wizard reaches this method on the PickClub visibility step
|
||||
// (issue #112 follow-up); using CommandDefinition here would fall back
|
||||
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
|
||||
// and throws PlatformNotSupportedException on AOT. Same root cause as
|
||||
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
|
||||
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 = @Platform
|
||||
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>(
|
||||
sql,
|
||||
new { Platform = "Telegram", ExternalId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
private static bool TryParseChatId(string raw, out long chatId)
|
||||
{
|
||||
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
chatId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseMessageId(string? raw, out int messageId)
|
||||
{
|
||||
if (raw is not null &&
|
||||
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
messageId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseThreadId(string? raw, out int threadId)
|
||||
{
|
||||
if (raw is not null &&
|
||||
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
threadId = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,68 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Telegram <see cref="Update"/> into the
|
||||
/// platform-neutral <see cref="WizardInteraction"/> consumed by
|
||||
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
|
||||
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
|
||||
/// shared wizard contract, so a future Discord adapter can do the same
|
||||
/// for its native event without changing the wizard core.
|
||||
/// </summary>
|
||||
public static class WizardInteractionMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if <paramref name="update"/> carries a
|
||||
/// wizard-relevant interaction (text message, photo, or
|
||||
/// callback). Side-effect-free: the wizard state is not touched.
|
||||
/// </summary>
|
||||
public static bool TryMap(Update update, out WizardInteraction interaction)
|
||||
{
|
||||
interaction = default!;
|
||||
if (update.CallbackQuery is { } cb && cb.From is not null)
|
||||
{
|
||||
interaction = new WizardInteraction(
|
||||
OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Text: null,
|
||||
CallbackPayload: cb.Data,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: cb.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (update.Message is { From: not null } msg)
|
||||
{
|
||||
// The original Telegram wizard dispatched on
|
||||
// `msg.Text is null` to identify a non-text update (photo,
|
||||
// document, sticker, …) and only ran the text pipeline
|
||||
// otherwise. We preserve that semantic: a message that
|
||||
// carries a photo is a photo interaction even if it has a
|
||||
// caption. Text is null for photos; the wizard checks
|
||||
// PhotoFileId separately when Text is null.
|
||||
//
|
||||
// Note: `Message.MessageId` is exposed as a read-only
|
||||
// property in Telegram.Bot, so the mapper cannot embed the
|
||||
// numeric id in the interaction. Text interactions never
|
||||
// need an ack, so the InteractionId is unused for them —
|
||||
// we just emit a stable sentinel.
|
||||
var hasPhoto = msg.Photo is { Length: > 0 };
|
||||
var text = hasPhoto ? null : msg.Text;
|
||||
var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null;
|
||||
interaction = new WizardInteraction(
|
||||
OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Text: text,
|
||||
CallbackPayload: null,
|
||||
PhotoFileId: photoFileId,
|
||||
PhotoUrl: null,
|
||||
InteractionId: "msg");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
|
||||
/// between the platform-neutral <see cref="WizardAction"/> list
|
||||
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
|
||||
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
|
||||
/// becomes its own row (matching the pre-refactor Telegram layout).
|
||||
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
|
||||
/// because the platform has no native primary/danger/success button
|
||||
/// colours.
|
||||
/// </summary>
|
||||
public static class WizardStep
|
||||
{
|
||||
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
|
||||
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
|
||||
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
|
||||
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
|
||||
public const int MinCapacity = WizardStepLimits.MinCapacity;
|
||||
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
|
||||
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
|
||||
|
||||
/// <summary>
|
||||
/// Render the platform-neutral view into a (text, Telegram keyboard)
|
||||
/// pair. Used by the wizard's surrounding code (router, create
|
||||
/// handler) when it needs to send a fresh draft message or render
|
||||
/// the resume/reset menu.
|
||||
/// </summary>
|
||||
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
|
||||
WizardDraft draft,
|
||||
WizardPayload payload,
|
||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||
{
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||
return (text, ToInlineKeyboard(actions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a flat list of <see cref="WizardAction"/>s into a
|
||||
/// Telegram keyboard. Each action is placed in its own row to
|
||||
/// preserve the pre-refactor visual layout.
|
||||
/// </summary>
|
||||
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
|
||||
}
|
||||
var rows = new InlineKeyboardButton[actions.Count][];
|
||||
for (var i = 0; i < actions.Count; i++)
|
||||
{
|
||||
rows[i] = new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
|
||||
};
|
||||
}
|
||||
return new InlineKeyboardMarkup(rows);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||
using System.Globalization;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||
@@ -14,6 +17,7 @@ using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
@@ -34,12 +38,47 @@ public sealed class UpdateRouter(
|
||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||
SharedWizard wizard,
|
||||
IWizardDraftRepository drafts,
|
||||
ITelegramBotClient bot,
|
||||
IConfiguration configuration,
|
||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||
{
|
||||
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 _, out _, out var ownerId))
|
||||
{
|
||||
var draft = await drafts.GetActiveAsync("Telegram", 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;
|
||||
}
|
||||
|
||||
if (WizardInteractionMapper.TryMap(update, out var interaction))
|
||||
{
|
||||
await wizard.HandleInteractionAsync(interaction, 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)
|
||||
{
|
||||
case { CallbackQuery: { } query }:
|
||||
@@ -63,9 +102,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) =>
|
||||
SharedWizard.LoadPayload(draft);
|
||||
|
||||
internal static string GetCommandText(Message message)
|
||||
=> (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 string ownerId)
|
||||
{
|
||||
chatId = 0;
|
||||
messageThreadId = null;
|
||||
ownerId = string.Empty;
|
||||
|
||||
switch (update)
|
||||
{
|
||||
case { Message: { From: not null, Chat: { } chat } msg }:
|
||||
chatId = chat.Id;
|
||||
messageThreadId = msg.MessageThreadId;
|
||||
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
|
||||
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
||||
chatId = cbmChat.Id;
|
||||
messageThreadId = cb.Message?.MessageThreadId;
|
||||
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||
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.ToString(CultureInfo.InvariantCulture);
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
||||
{
|
||||
if (query.Data is not { } data || query.Message is not { } message)
|
||||
@@ -213,7 +349,7 @@ public sealed class UpdateRouter(
|
||||
break;
|
||||
|
||||
case "/newsession":
|
||||
await createSessionHandler.HandleAsync(message, ct);
|
||||
await HandleNewSessionCommandAsync(message, ct);
|
||||
break;
|
||||
|
||||
case "/listsessions":
|
||||
@@ -256,6 +392,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)
|
||||
{
|
||||
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
|
||||
@@ -276,3 +451,14 @@ public sealed class UpdateRouter(
|
||||
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);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- V032: Platform-neutral wizard drafts (issue #112).
|
||||
-- Adds the platform discriminator and switches owner/chat/thread/message
|
||||
-- columns from numeric to TEXT so the same table can hold both Telegram
|
||||
-- ids (long) and Discord snowflakes (ulong). All conversions are safe:
|
||||
-- the affected columns are nullable except chat_id/owner_telegram_id
|
||||
-- which we cast via TEXT.
|
||||
|
||||
ALTER TABLE wizard_drafts
|
||||
ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
|
||||
|
||||
-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids
|
||||
-- which convert losslessly to their decimal string form.
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT;
|
||||
|
||||
-- Convert message_thread_id: INT (nullable) → TEXT (nullable).
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT;
|
||||
|
||||
-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable).
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT;
|
||||
|
||||
-- Rename owner_telegram_id → owner_id (now platform-agnostic) and
|
||||
-- convert from BIGINT to TEXT.
|
||||
ALTER TABLE wizard_drafts
|
||||
RENAME COLUMN owner_telegram_id TO owner_id;
|
||||
|
||||
ALTER TABLE wizard_drafts
|
||||
ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT;
|
||||
|
||||
-- Replace the old owner lookup index with one that uses the new column
|
||||
-- names and the platform discriminator.
|
||||
DROP INDEX IF EXISTS idx_wizard_drafts_owner;
|
||||
|
||||
CREATE INDEX idx_wizard_drafts_owner
|
||||
ON wizard_drafts(platform, owner_id);
|
||||
|
||||
CREATE INDEX idx_wizard_drafts_platform
|
||||
ON wizard_drafts(platform);
|
||||
@@ -1,5 +1,6 @@
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
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.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
@@ -68,6 +70,11 @@ builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<GmRelay.Shared.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<IWizardMessenger, TelegramWizardMessenger>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
@@ -101,6 +108,7 @@ builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
// ── Session scheduler ────────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||
builder.Services.AddHostedService<WizardDraftCleanupService>();
|
||||
|
||||
// ── Health check server ──────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<BotHealthCheckHostedService>();
|
||||
|
||||
@@ -32,7 +32,9 @@ public sealed class DiscordDeleteSessionHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
@@ -43,6 +45,39 @@ public sealed class DiscordDeleteSessionHandler(
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||
transaction: transaction);
|
||||
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
AND pg.is_public = true
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
var deletedRows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM sessions s
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Small lookup helper for Discord permission checks. The
|
||||
/// <see cref="DiscordNewSessionHandler"/> already runs the same SQL
|
||||
/// inline; this class is here so the wizard slash command can do the
|
||||
/// same check without duplicating the query string.
|
||||
/// </summary>
|
||||
internal static class DiscordPermissionLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<ulong>> LoadManagerUserIdsAsync(
|
||||
NpgsqlDataSource dataSource,
|
||||
ulong guildId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||
var rows = await connection.QueryAsync<ulong>(
|
||||
sql,
|
||||
new { GuildId = guildId.ToString() });
|
||||
return rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Slash entry point for the Discord wizard. Mirrors the Telegram
|
||||
/// <c>/newsession-wizard</c> command: a fresh draft is created on
|
||||
/// first invocation, the persisted first-step message is re-shown
|
||||
/// when the user already has an active draft, and the owner/co-GM
|
||||
/// permission check from <see cref="DiscordPermissionChecker"/> is
|
||||
/// applied before any draft is created.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordWizardMessenger _messenger;
|
||||
private readonly DiscordPermissionChecker _permissions;
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<DiscordWizardCommand> _log;
|
||||
|
||||
public DiscordWizardCommand(
|
||||
DiscordWizardMessenger messenger,
|
||||
DiscordPermissionChecker permissions,
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<DiscordWizardCommand> log)
|
||||
{
|
||||
_messenger = messenger;
|
||||
_permissions = permissions;
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_dataSource = dataSource;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[SlashCommand("newsession-wizard", "Пошаговое создание игры или пула")]
|
||||
public async Task ExecuteAsync(
|
||||
[SlashCommandParameter(Name = "mode", Description = "Пропустить выбор типа (single/pool)")] string? mode = null)
|
||||
{
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var channel = Context.Channel
|
||||
?? throw new InvalidOperationException("Channel data not available in interaction.");
|
||||
var channelId = channel.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var member = Context.User as GuildInteractionUser
|
||||
?? throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
var userId = Context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Slash commands don't expose a CancellationToken on the context;
|
||||
// the REST call already has its own per-request cancellation. We
|
||||
// pass CancellationToken.None for the DB calls — they're cheap and
|
||||
// the host's shutdown will tear them down.
|
||||
var ct = CancellationToken.None;
|
||||
var ownerId = userId;
|
||||
ulong guildOwnerId = 0;
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_log.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; falling back to permissions from interaction payload.",
|
||||
guildId);
|
||||
}
|
||||
|
||||
// Permission check: server owner, guild admin, or DB manager
|
||||
// for the Discord game_group.
|
||||
var dbManagerIds = await DiscordPermissionLookup.LoadManagerUserIdsAsync(
|
||||
_dataSource, guildId, ct);
|
||||
if (!_permissions.CanManageSchedule(guildOwnerId, Context.User.Id, dbManagerIds, resolvedPermissions))
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("⛔ Только owner, администратор или manager могут создавать сессии.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's already a draft, offer Continue / Start over.
|
||||
var existing = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (existing is not null && existing.Id != Guid.Empty)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📝 У вас уже есть активный мастер. Продолжить?")
|
||||
.WithFlags(MessageFlags.Ephemeral)
|
||||
.WithComponents(BuildResumeRow(existing.Id))));
|
||||
return;
|
||||
}
|
||||
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = guildId.ToString(CultureInfo.InvariantCulture),
|
||||
MessageThreadId = null,
|
||||
OwnerId = ownerId,
|
||||
Platform = "Discord",
|
||||
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
|
||||
? WizardStepNames.Title
|
||||
: WizardStepNames.Type,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
// If the user passed `mode=pool` we pre-seed the payload so the
|
||||
// wizard's own branching lands on the pool flow.
|
||||
if (NormalizeMode(mode) is { } mt)
|
||||
{
|
||||
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new WizardPayload { Type = mt },
|
||||
WizardPayloadJsonContext.Default.WizardPayload);
|
||||
}
|
||||
|
||||
// Stash the context BEFORE sending so the messenger can both
|
||||
// send the message and persist the returned message id back.
|
||||
_contextStore.Set(draft.Id, new DiscordWizardContext(
|
||||
GuildId: guildId.ToString(CultureInfo.InvariantCulture),
|
||||
ChannelId: channelId,
|
||||
MessageId: string.Empty,
|
||||
ThreadId: null));
|
||||
|
||||
// Render + send the first step. Defer the response so we can
|
||||
// show the wizard message in the channel.
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
|
||||
try
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
|
||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||
draft.DraftMessageId = msgId;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(msg =>
|
||||
{
|
||||
msg.Content = "🎲 Мастер запущен. См. сообщение ниже.";
|
||||
msg.Flags = MessageFlags.Ephemeral;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Failed to start wizard for user {OwnerId} in guild {GuildId}", ownerId, guildId);
|
||||
_contextStore.Remove(draft.Id);
|
||||
try
|
||||
{
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* best effort */
|
||||
}
|
||||
await Context.Interaction.ModifyResponseAsync(msg =>
|
||||
{
|
||||
msg.Content = "💥 Не удалось запустить мастер. Попробуйте позже.";
|
||||
msg.Flags = MessageFlags.Ephemeral;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static WizardCreationType? NormalizeMode(string? mode) =>
|
||||
mode?.ToLowerInvariant() switch
|
||||
{
|
||||
"single" or "одну" or "one" => WizardCreationType.Single,
|
||||
"pool" or "пул" => WizardCreationType.Pool,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
string.IsNullOrEmpty(draft.PayloadJson)
|
||||
? new WizardPayload()
|
||||
: System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
|
||||
private static IReadOnlyList<IMessageComponentProperties> BuildResumeRow(Guid draftId)
|
||||
{
|
||||
// Direct format strings (not ChoiceButtonCustomId) — these
|
||||
// are control actions (resume:cancel), not wizard-step choices.
|
||||
// The dispatcher's switch matches parts[1] as "resume" or
|
||||
// "cancel" directly, not as a "choice" prefix.
|
||||
var row = new ActionRowProperties();
|
||||
row.Add(new ButtonProperties(
|
||||
"wizard:btn:resume:continue",
|
||||
"▶️ Продолжить",
|
||||
ButtonStyle.Primary));
|
||||
row.Add(new ButtonProperties(
|
||||
"wizard:btn:resume:restart",
|
||||
"🔄 Заново",
|
||||
ButtonStyle.Secondary));
|
||||
row.Add(new ButtonProperties(
|
||||
"wizard:btn:cancel:1",
|
||||
"❌ Отмена",
|
||||
ButtonStyle.Danger));
|
||||
return new IMessageComponentProperties[] { row };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of where the wizard's draft message lives. The messenger
|
||||
/// needs this to re-send / re-edit the message after a 15-minute
|
||||
/// interaction token has expired.
|
||||
/// </summary>
|
||||
/// <param name="GuildId">Discord guild (server) id as a decimal string.</param>
|
||||
/// <param name="ChannelId">Channel id where the draft message was posted.</param>
|
||||
/// <param name="MessageId">Id of the currently active draft message.</param>
|
||||
/// <param name="ThreadId">Optional thread id; <c>null</c> for top-level channel posts.</param>
|
||||
public sealed record DiscordWizardContext(
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId,
|
||||
string? ThreadId);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store of draft → context lookups. Lives for the lifetime of
|
||||
/// the process; the wizard's 24-hour expiry is enforced by
|
||||
/// <c>WizardDraftCleanupService</c>, so this cache is allowed to hold
|
||||
/// entries until the draft is finalized or explicitly removed.
|
||||
/// </summary>
|
||||
public interface IWizardContextStore
|
||||
{
|
||||
void Set(Guid draftId, DiscordWizardContext context);
|
||||
|
||||
bool TryGet(Guid draftId, out DiscordWizardContext context);
|
||||
|
||||
void Remove(Guid draftId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation. Concurrent dictionary keyed by
|
||||
/// <see cref="Guid"/> is sufficient for a single-process Discord bot.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardContextStore : IWizardContextStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, DiscordWizardContext> store = new();
|
||||
|
||||
public void Set(Guid draftId, DiscordWizardContext context) =>
|
||||
store[draftId] = context;
|
||||
|
||||
public bool TryGet(Guid draftId, out DiscordWizardContext context) =>
|
||||
store.TryGetValue(draftId, out context!);
|
||||
|
||||
public void Remove(Guid draftId) => store.TryRemove(draftId, out _);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound component-interaction handler for the Discord wizard.
|
||||
///
|
||||
/// One class per interaction context type — NetCord's
|
||||
/// <c>ComponentInteractionModule<TContext></c> is single-context. All
|
||||
/// three classes share the same dispatch table (parse customId → load
|
||||
/// draft → call the shared
|
||||
/// <see cref="SharedWizard.HandleInteractionAsync"/>) implemented in
|
||||
/// <see cref="WizardInteractionDispatcher"/>.
|
||||
///
|
||||
/// Custom-id wire format (see <see cref="DiscordWizardStep"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>wizard:btn:choice:<step>:<value></c> — choice buttons</item>
|
||||
/// <item><c>wizard:btn:cancel</c>, <c>wizard:btn:back</c>, <c>wizard:btn:create</c></item>
|
||||
/// <item><c>wizard:btn:resume:<continue|restart></c></item>
|
||||
/// <item><c>wizard:select:<step></c> — StringSelectMenu</item>
|
||||
/// <item><c>wizard:modal:<step></c> — Modal submit</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The active draft is looked up by (platform="Discord", ownerId=userId);
|
||||
/// the custom-id never carries a draft id because the wizard assumes one
|
||||
/// active draft per owner.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardButtonModule : ComponentInteractionModule<ButtonInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardButtonModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleButtonAsync(Context, args);
|
||||
}
|
||||
|
||||
public sealed class DiscordWizardStringMenuModule : ComponentInteractionModule<StringMenuInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardStringMenuModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleStringMenuAsync(Context, args);
|
||||
}
|
||||
|
||||
public sealed class DiscordWizardModalModule : ComponentInteractionModule<ModalInteractionContext>
|
||||
{
|
||||
private readonly WizardInteractionDispatcher _dispatcher;
|
||||
|
||||
public DiscordWizardModalModule(WizardInteractionDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[ComponentInteraction("wizard")]
|
||||
public Task HandleAsync(string args) =>
|
||||
_dispatcher.HandleModalAsync(Context, args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared dispatch table for the three wizard interaction modules.
|
||||
/// Owns all the stateful collaborators (drafts, context store, wizard
|
||||
/// state machine, submitter, messenger, reply cache) so the three
|
||||
/// NetCord module shells can stay trivially thin.
|
||||
/// </summary>
|
||||
public sealed class WizardInteractionDispatcher
|
||||
{
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly SharedWizard _wizard;
|
||||
private readonly DiscordWizardSubmitter _submitter;
|
||||
private readonly DiscordWizardMessenger _messenger;
|
||||
private readonly DiscordInteractionReplyCache _replies;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<WizardInteractionDispatcher> _log;
|
||||
|
||||
public WizardInteractionDispatcher(
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
SharedWizard wizard,
|
||||
DiscordWizardSubmitter submitter,
|
||||
DiscordWizardMessenger messenger,
|
||||
DiscordInteractionReplyCache replies,
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<WizardInteractionDispatcher> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_wizard = wizard;
|
||||
_submitter = submitter;
|
||||
_messenger = messenger;
|
||||
_replies = replies;
|
||||
_dataSource = dataSource;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steps that, after the wizard's state advance, expect the user
|
||||
/// to fill a popup. The dispatcher uses this to decide whether
|
||||
/// the interaction response is a Modal() (the user sees a popup)
|
||||
/// or a DeferredMessage() (the wizard's edit is the only visible
|
||||
/// feedback). Keep in sync with <see cref="DiscordWizardStep.OpenModalStep"/>
|
||||
/// returns.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlySet<string> StepsThatOpenModal = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
WizardStepNames.Title,
|
||||
WizardStepNames.Description,
|
||||
WizardStepNames.Cover,
|
||||
WizardStepNames.DateTime,
|
||||
WizardStepNames.Capacity,
|
||||
WizardStepNames.PoolSlotDateTime,
|
||||
WizardStepNames.PoolSlotCapacity,
|
||||
"SystemFreeText",
|
||||
"DurationFreeText",
|
||||
"PoolSystemDurationFreeText",
|
||||
};
|
||||
|
||||
// ── Button handler ────────────────────────────────────────────────
|
||||
public async Task HandleButtonAsync(ButtonInteractionContext context, string args)
|
||||
{
|
||||
// NetCord only allows one response per interaction. The
|
||||
// previous implementation deferred too early and then
|
||||
// tried to "swap" the deferred response for a Modal — which
|
||||
// NetCord forbids. The new flow is: do the wizard work
|
||||
// (which is a separate REST call to edit the draft message),
|
||||
// THEN send the interaction response. The response is
|
||||
// either a Modal popup (when the new step needs text input)
|
||||
// or a plain DeferredMessage ack.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like one of:
|
||||
// "btn:choice:<step>:<value>" (a choice)
|
||||
// "btn:cancel"
|
||||
// "btn:back"
|
||||
// "btn:create"
|
||||
// "btn:resume:continue"
|
||||
// "btn:resume:restart"
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = args.Split(':', 4);
|
||||
if (parts.Length < 2 || parts[0] != "btn")
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестная команда");
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: "create" doesn't go through the wizard — the
|
||||
// submitter edits the draft message directly with the result
|
||||
// embed ("✅ Создано" or retry buttons). After the submitter
|
||||
// returns, ack the click so the user doesn't see "Application
|
||||
// did not respond".
|
||||
if (parts[1] == "create")
|
||||
{
|
||||
await _submitter.SubmitAsync(draft, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: "modal:<step>" — the renderer emits this for the
|
||||
// "Другое…" free-text buttons on System, Duration, and
|
||||
// PoolSystemDuration. The click intent is "open a modal for
|
||||
// free-text input" — NOT "advance the wizard". The wizard's
|
||||
// state advance happens when the user submits the modal.
|
||||
if (parts[1] == "modal" && parts.Length >= 3)
|
||||
{
|
||||
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
|
||||
if (modal is not null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||
}
|
||||
else
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Не удалось открыть форму");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: "resume" — the slash command's resume row
|
||||
// gives the user a chance to keep or restart their active
|
||||
// draft. The wizard has no built-in resume case, so we
|
||||
// handle the two resume kinds directly.
|
||||
if (parts[1] == "resume")
|
||||
{
|
||||
await HandleResumeAsync(context, parts, draft, ownerId, interactionId, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Choice / back / cancel — route through the shared wizard.
|
||||
string callback;
|
||||
switch (parts[1])
|
||||
{
|
||||
case "choice":
|
||||
if (parts.Length < 4)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Некорректная кнопка");
|
||||
return;
|
||||
}
|
||||
callback = WizardCallbackData.Choice(parts[2], parts[3]);
|
||||
break;
|
||||
case "back":
|
||||
callback = WizardCallbackData.Back();
|
||||
break;
|
||||
case "cancel":
|
||||
callback = WizardCallbackData.Cancel();
|
||||
break;
|
||||
default:
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестная кнопка");
|
||||
return;
|
||||
}
|
||||
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: callback,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
|
||||
// After the wizard's state advance, decide the response.
|
||||
// The wizard's EditDraftMessageAsync already updated the
|
||||
// draft embed; we just need the interaction response to
|
||||
// either pop a modal or quietly ack.
|
||||
if (parts[1] == "cancel")
|
||||
{
|
||||
_contextStore.Remove(draft.Id);
|
||||
}
|
||||
await RespondAfterWizardAsync(context, draft, ct);
|
||||
}
|
||||
|
||||
private async Task HandleResumeAsync(
|
||||
ButtonInteractionContext context,
|
||||
string[] parts,
|
||||
WizardDraft draft,
|
||||
string ownerId,
|
||||
string interactionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// resume:continue → re-render the current step (the wizard
|
||||
// itself doesn't know about resume, so we just edit the
|
||||
// draft message via the messenger).
|
||||
// resume:restart → delete the draft and prompt the user to
|
||||
// re-run /newsession-wizard.
|
||||
if (parts.Length >= 3 && parts[2] == "restart")
|
||||
{
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("♻️ Мастер сброшен. Запустите /newsession-wizard заново.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
// continue
|
||||
var payload = LoadPayload(draft);
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, await LoadClubsAsync(draft, ct));
|
||||
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
}
|
||||
|
||||
// ── StringSelectMenu handler ───────────────────────────────────────
|
||||
public async Task HandleStringMenuAsync(StringMenuInteractionContext context, string args)
|
||||
{
|
||||
// NetCord's interaction response type is locked after the
|
||||
// first SendResponse call. For menu selections we don't
|
||||
// need a popup, so we always defer. The wizard's edit is
|
||||
// a separate REST call.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like "select:<step>" (e.g. "select:Visibility" or
|
||||
// "select:PoolSystemDuration"). The chosen value lives in
|
||||
// SelectedValues[0].
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
// NetCord strips the matching ComponentInteraction "wizard"
|
||||
// prefix from the custom id and passes the remainder as `args`.
|
||||
// For `wizard:select:Visibility` the args arrive as
|
||||
// `select:Visibility` (2 parts when split on `:`), so the
|
||||
// length check uses `< 2` and the step lives at parts[1].
|
||||
var parts = args.Split(':', 2);
|
||||
if (parts.Length < 2 || parts[0] != "select" || context.Interaction.Data.SelectedValues.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Неизвестный выбор");
|
||||
return;
|
||||
}
|
||||
var step = parts[1];
|
||||
var value = context.Interaction.Data.SelectedValues[0];
|
||||
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredModifyMessage);
|
||||
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: null,
|
||||
CallbackPayload: WizardCallbackData.Choice(step, value),
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
|
||||
// ── Modal submit handler ──────────────────────────────────────────
|
||||
public async Task HandleModalAsync(ModalInteractionContext context, string args)
|
||||
{
|
||||
// The modal text becomes the user's input. The wizard's
|
||||
// ApplyText dispatcher consumes it as either a text-input
|
||||
// step (Title, Description, etc.) or, via the helper, a
|
||||
// free-text variant of System/Duration/PoolSystemDuration.
|
||||
var ct = CancellationToken.None;
|
||||
// args looks like "modal:<step>" (e.g. "modal:Title" or
|
||||
// "modal:SystemFreeText"). The text value lives in
|
||||
// Data.Components[0].Component.Value (the wizard sends a
|
||||
// single Label wrapping a single TextInput).
|
||||
var ownerId = context.User.Id.ToString(CultureInfo.InvariantCulture);
|
||||
var interactionId = context.Interaction.Id.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var draft = await _drafts.GetActiveAsync("Discord", ownerId, ct);
|
||||
if (draft is null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent("📭 Нет активного мастера. Запустите /newsession-wizard.")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Same NetCord prefix-stripping as the select handler:
|
||||
// for `wizard:modal:Title` the args arrive as `modal:Title`
|
||||
// (2 parts when split on `:`).
|
||||
var parts = args.Split(':', 2);
|
||||
if (parts.Length < 2 || parts[0] != "modal" || context.Interaction.Data.Components.Count == 0)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Некорректный модал");
|
||||
return;
|
||||
}
|
||||
var step = parts[1];
|
||||
|
||||
var text = ExtractModalText(context);
|
||||
if (text is null)
|
||||
{
|
||||
await AckWithErrorAsync(context.Interaction, "Модал без ввода");
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal values are routed by step name. The shared wizard knows
|
||||
// how to apply Title, Description, etc.; the free-text variants
|
||||
// (SystemFreeText, DurationFreeText, PoolSystemDurationFreeText)
|
||||
// are mapped to the canonical step here so the wizard's existing
|
||||
// ApplyText dispatcher handles them.
|
||||
var wizardStep = MapModalStepToWizardStep(step);
|
||||
var interaction = new WizardInteraction(
|
||||
OwnerId: ownerId,
|
||||
Text: text,
|
||||
CallbackPayload: null,
|
||||
PhotoFileId: null,
|
||||
PhotoUrl: null,
|
||||
InteractionId: interactionId);
|
||||
|
||||
// For free-text modal steps the wizard's "current step" is the
|
||||
// canonical step (System, Duration, etc.), but the user just
|
||||
// submitted via the free-text modal. Temporarily set the
|
||||
// draft.Step to the canonical step so the wizard's ApplyText
|
||||
// runs the right branch. The wizard then advances draft.Step
|
||||
// to the NEXT step (e.g. Duration) and persists that via
|
||||
// _drafts.UpsertAsync. We must NOT restore draft.Step to
|
||||
// the original value afterwards — the DB has already been
|
||||
// updated to the new step, and restoring locally would only
|
||||
// mask the truth from the next interaction's GetActiveAsync.
|
||||
if (draft.Step != wizardStep)
|
||||
{
|
||||
draft.Step = wizardStep;
|
||||
}
|
||||
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
|
||||
await _wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||
}
|
||||
|
||||
// ── Response helper ───────────────────────────────────────────────
|
||||
private async Task RespondAfterWizardAsync(
|
||||
ButtonInteractionContext context,
|
||||
WizardDraft draft,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// The wizard's state machine has advanced draft.Step. Re-render
|
||||
// the new step locally to discover whether it expects a popup,
|
||||
// then send the appropriate response.
|
||||
try
|
||||
{
|
||||
if (StepsThatOpenModal.Contains(draft.Step))
|
||||
{
|
||||
var modal = DiscordWizardStep.BuildModal(draft.Step, draft.ChatId);
|
||||
if (modal is not null)
|
||||
{
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Modal popup failed for step {Step}; falling back to ack.", draft.Step);
|
||||
}
|
||||
// No popup needed — the wizard's edit is the only visible
|
||||
// feedback. Acknowledge with a deferred message so Discord
|
||||
// doesn't show "Application did not respond".
|
||||
await context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage(
|
||||
MessageFlags.Ephemeral));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
private static string ExtractModalText(ModalInteractionContext context)
|
||||
{
|
||||
// The wizard builds each modal with a single Label wrapping a
|
||||
// single TextInput. We walk the (Component → Component) chain.
|
||||
if (context.Interaction.Data.Components.Count == 0) return null!;
|
||||
var first = context.Interaction.Data.Components[0];
|
||||
if (first is Label label && label.Component is TextInput input)
|
||||
{
|
||||
return input.Value ?? string.Empty;
|
||||
}
|
||||
return null!;
|
||||
}
|
||||
|
||||
private static string MapModalStepToWizardStep(string modalStep) => modalStep switch
|
||||
{
|
||||
// Free-text modals map back to the canonical wizard step that
|
||||
// knows how to apply the text.
|
||||
"SystemFreeText" => WizardStepNames.System,
|
||||
"DurationFreeText" => WizardStepNames.Duration,
|
||||
"PoolSystemDurationFreeText" => WizardStepNames.PoolSystemDuration,
|
||||
// Direct mappings.
|
||||
_ => modalStep,
|
||||
};
|
||||
|
||||
private async Task<IReadOnlyList<WizardClubOption>?> LoadClubsAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
// Inline the same query the messenger would run, so the
|
||||
// dispatcher's PickClub step sees the owner's real club list
|
||||
// instead of an empty array.
|
||||
return await WizardClubLookup.LoadClubsAsync(_dataSource, draft.OwnerId, ct);
|
||||
}
|
||||
|
||||
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||
string.IsNullOrEmpty(draft.PayloadJson)
|
||||
? new WizardPayload()
|
||||
: System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
|
||||
private static async Task AckWithErrorAsync(Interaction interaction, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
await interaction.SendResponseAsync(InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent($"⚠️ {text}")
|
||||
.WithFlags(MessageFlags.Ephemeral)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standalone helper that queries the owner-club list without going
|
||||
/// through <see cref="DiscordWizardMessenger"/>. The dispatcher needs
|
||||
/// the list at the PickClub step; reusing the messenger's GetOwnerClubsAsync
|
||||
/// would create a circular DI graph (the messenger depends on
|
||||
/// IWizardContextStore which the dispatcher also needs).
|
||||
/// </summary>
|
||||
internal static class WizardClubLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<WizardClubOption>> LoadClubsAsync(
|
||||
NpgsqlDataSource dataSource, string ownerId, CancellationToken ct)
|
||||
{
|
||||
// Same SQL the messenger runs for the wizard's render path.
|
||||
// Filter by Owner|CoGm role and group by club to dedupe.
|
||||
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 = @Platform
|
||||
AND p.external_user_id = @OwnerId
|
||||
AND gm.role IN ('Owner', 'CoGm')
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||
sql,
|
||||
new { Platform = "Discord", OwnerId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Discord-side implementation of <see cref="IWizardMessenger"/>.
|
||||
/// Translates the platform-neutral wizard contract into NetCord REST
|
||||
/// calls and ephemeral follow-ups. The messenger has no access to the
|
||||
/// live interaction context — that lives on the inbound handler — so
|
||||
/// <see cref="AnswerInteractionAsync"/> stashes the toast text in
|
||||
/// <see cref="DiscordInteractionReplyCache"/>; the inbound module
|
||||
/// drains the cache and ships the actual <c>SendResponseAsync</c> call
|
||||
/// via the existing helper used by <c>DiscordPlatformMessenger</c>.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardMessenger : IWizardMessenger
|
||||
{
|
||||
private readonly RestClient _rest;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly DiscordInteractionReplyCache _replies;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly ILogger<DiscordWizardMessenger>? _log;
|
||||
|
||||
public DiscordWizardMessenger(
|
||||
RestClient rest,
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordInteractionReplyCache replies,
|
||||
IWizardContextStore contextStore)
|
||||
: this(rest, dataSource, replies, contextStore, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public DiscordWizardMessenger(
|
||||
RestClient rest,
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordInteractionReplyCache replies,
|
||||
IWizardContextStore contextStore,
|
||||
ILogger<DiscordWizardMessenger>? logger)
|
||||
{
|
||||
_rest = rest;
|
||||
_dataSource = dataSource;
|
||||
_replies = replies;
|
||||
_contextStore = contextStore;
|
||||
_log = logger;
|
||||
}
|
||||
|
||||
public async Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_contextStore.TryGet(draft.Id, out var ctx))
|
||||
{
|
||||
// No stored context (e.g. service restart, draft from another
|
||||
// process). Fall back to sending a brand new message — the
|
||||
// caller will persist the returned id.
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
|
||||
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
|
||||
!ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
|
||||
{
|
||||
// Context is corrupt — recreate the message.
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
|
||||
await _rest.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = embed is null ? null : new[] { embed };
|
||||
options.Components = rows;
|
||||
});
|
||||
return ctx.MessageId;
|
||||
}
|
||||
catch (RestException ex) when (IsExpiredOrUnknownMessage(ex))
|
||||
{
|
||||
// Message was deleted or interaction token expired —
|
||||
// recreate the message in the original channel.
|
||||
_log?.LogWarning(
|
||||
ex,
|
||||
"Edit failed for draft {DraftId} (channel={ChannelId}, message={MessageId}); re-sending.",
|
||||
draft.Id,
|
||||
ctx.ChannelId,
|
||||
ctx.MessageId);
|
||||
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_contextStore.TryGet(draft.Id, out var ctx))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot send wizard message: no context for draft {draft.Id}.");
|
||||
}
|
||||
|
||||
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard draft {draft.Id} has un-parseable channel id '{ctx.ChannelId}'.");
|
||||
}
|
||||
|
||||
var (embed, rows) = BuildEmbedAndRows(draft, text, keyboard);
|
||||
var message = await _rest.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds(embed is null ? null : new[] { embed })
|
||||
.WithComponents(rows));
|
||||
|
||||
var newMessageId = message.Id.ToString(CultureInfo.InvariantCulture);
|
||||
_contextStore.Set(draft.Id, ctx with { MessageId = newMessageId });
|
||||
return newMessageId;
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||
{
|
||||
// The wizard's "answer" is just a toast shown to the user.
|
||||
// Stash it in the existing reply cache; the inbound interaction
|
||||
// module drains it once the wizard returns. ShowAlert=false so
|
||||
// it appears as a quiet follow-up rather than a popup.
|
||||
_replies.Store(new PlatformInteractionReply(
|
||||
InteractionId: interactionId,
|
||||
Text: text ?? string.Empty,
|
||||
ShowAlert: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(
|
||||
string ownerId, CancellationToken ct)
|
||||
{
|
||||
// The Telegram messenger enumerates game_groups the owner
|
||||
// manages as a GM (V008 added group_managers with role
|
||||
// 'Owner'|'CoGm'). Discord follows the same convention.
|
||||
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 = @Platform
|
||||
AND p.external_user_id = @ExternalId
|
||||
AND gm.role IN ('Owner', 'CoGm')
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
// NativeAOT: direct (sql, params) overload — see
|
||||
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
|
||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||
sql,
|
||||
new { Platform = "Discord", ExternalId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
// ── Embed + component construction ────────────────────────────────
|
||||
private (EmbedProperties? embed, IReadOnlyList<IMessageComponentProperties> rows) BuildEmbedAndRows(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard)
|
||||
{
|
||||
// Embeds have a hard 4096-char limit — truncate to 3900 so the
|
||||
// wizard's own prefix/suffix additions still fit.
|
||||
var safeText = Truncate(text, 3900);
|
||||
var rows = BuildActionRowsFromActions(keyboard);
|
||||
return (BuildEmbed(safeText), rows);
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildEmbed(string description) =>
|
||||
new EmbedProperties()
|
||||
.WithTitle("Мастер создания сессии")
|
||||
.WithDescription(description)
|
||||
.WithColor(new Color(0x5865F2));
|
||||
|
||||
private static string Truncate(string text, int max) =>
|
||||
text.Length <= max ? text : text[..max];
|
||||
|
||||
private static IReadOnlyList<IMessageComponentProperties> BuildActionRowsFromActions(
|
||||
IReadOnlyList<WizardAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return Array.Empty<IMessageComponentProperties>();
|
||||
}
|
||||
var rows = new List<IMessageComponentProperties>();
|
||||
// Discord allows up to 5 buttons per ActionRow. Lay them out
|
||||
// left-to-right, 5 per row.
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
var style = action.Style switch
|
||||
{
|
||||
WizardActionStyle.Primary => ButtonStyle.Primary,
|
||||
WizardActionStyle.Success => ButtonStyle.Success,
|
||||
WizardActionStyle.Danger => ButtonStyle.Danger,
|
||||
_ => ButtonStyle.Secondary,
|
||||
};
|
||||
var cid = action.Payload;
|
||||
if (cid.Length > DiscordWizardStep.MaxCustomIdLength)
|
||||
{
|
||||
// Truncate-by-omission isn't safe (customId must be
|
||||
// unique). The wizard's callback format is already
|
||||
// bounded — if we hit this, it's a bug.
|
||||
throw new InvalidOperationException(
|
||||
$"Wizard action custom id '{cid}' exceeds Discord's 100-char limit.");
|
||||
}
|
||||
row.Add(new ButtonProperties(cid, action.Label, style));
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static bool IsExpiredOrUnknownMessage(RestException ex) =>
|
||||
ex.StatusCode == System.Net.HttpStatusCode.NotFound
|
||||
|| ex.StatusCode == System.Net.HttpStatusCode.Unauthorized
|
||||
|| ex.StatusCode == System.Net.HttpStatusCode.Forbidden;
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a wizard step into a Discord embed + components row + an
|
||||
/// optional modal popup. Lives in the DiscordBot project because the
|
||||
/// platform-neutral <see cref="WizardStepViewBuilder"/> doesn't know
|
||||
/// about embeds, action rows, or modals.
|
||||
///
|
||||
/// The renderer also exposes <see cref="OpenModal"/> so the slash
|
||||
/// command can return a "popup" response to the user (Telegram's
|
||||
/// "ForceReply" equivalent). Discord modals are limited to 5 components
|
||||
/// (typically labels wrapping text inputs) and a 45-character title.
|
||||
/// </summary>
|
||||
public static class DiscordWizardStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Discord custom-id budget is 100 characters. We pad our own
|
||||
/// identifiers to stay under it.
|
||||
/// </summary>
|
||||
public const int MaxCustomIdLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel returned in <see cref="DiscordWizardRender.OpenModalStep"/>
|
||||
/// when the renderer wants the interaction module to open a modal
|
||||
/// for the given step. The step name is the <see cref="WizardStepNames"/>
|
||||
/// value (e.g. <c>Title</c>, <c>DateTime</c>).
|
||||
/// </summary>
|
||||
public sealed record DiscordWizardRender(
|
||||
string EmbedTitle,
|
||||
string EmbedDescription,
|
||||
IReadOnlyList<IMessageComponentProperties> Components,
|
||||
string? OpenModalStep);
|
||||
|
||||
/// <summary>
|
||||
/// Build the embed + components for a wizard step. The caller is
|
||||
/// responsible for sending the message via
|
||||
/// <see cref="DiscordWizardMessenger"/>.
|
||||
/// </summary>
|
||||
public static DiscordWizardRender 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 ?? System.Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => RenderPublish(),
|
||||
WizardStepNames.Confirm => RenderConfirm(payload),
|
||||
|
||||
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
|
||||
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
|
||||
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
|
||||
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
|
||||
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
|
||||
|
||||
_ => throw new System.InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Custom-id helpers ─────────────────────────────────────────────
|
||||
// Three custom-id shapes for buttons, all with the literal "wizard" prefix
|
||||
// that the NetCord [ComponentInteraction("wizard")] matcher strips off.
|
||||
// After prefix-strip the dispatcher receives the suffix as `args`.
|
||||
//
|
||||
// Choice : wizard:btn:choice:<step>:<value> → wizard's ApplyChoice
|
||||
// Control : wizard:btn:<action>:1 → dispatcher special case
|
||||
// Modal trig. : wizard:btn:modal:<modalStep> → dispatcher opens modal
|
||||
//
|
||||
// The renderer's helpers below enforce these shapes so the dispatcher
|
||||
// parser and the wizard callbacks stay in lockstep.
|
||||
public static string ChoiceButtonCustomId(string step, string value) =>
|
||||
$"wizard:btn:choice:{step}:{value}";
|
||||
|
||||
public static string ControlButtonCustomId(string action) =>
|
||||
$"wizard:btn:{action}:1";
|
||||
|
||||
public static string ModalTriggerButtonCustomId(string modalStep) =>
|
||||
$"wizard:btn:modal:{modalStep}";
|
||||
|
||||
public static string SelectCustomId(string step) => $"wizard:select:{step}";
|
||||
|
||||
public static string ModalCustomId(string step) => $"wizard:modal:{step}";
|
||||
|
||||
public static bool TryParseButtonCustomId(string customId, out string step, out string value)
|
||||
{
|
||||
step = value = string.Empty;
|
||||
var parts = customId.Split(':', 5);
|
||||
if (parts.Length < 5 || parts[0] != "wizard" || parts[1] != "btn" || parts[2] != "choice")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
step = parts[3];
|
||||
value = parts[4];
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryParseSelectCustomId(string customId, out string step)
|
||||
{
|
||||
step = string.Empty;
|
||||
var parts = customId.Split(':', 3);
|
||||
return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "select" && (step = parts[2]) is not null;
|
||||
}
|
||||
|
||||
public static bool TryParseModalCustomId(string customId, out string step)
|
||||
{
|
||||
step = string.Empty;
|
||||
var parts = customId.Split(':', 3);
|
||||
return parts.Length >= 3 && parts[0] == "wizard" && parts[1] == "modal" && (step = parts[2]) is not null;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
// Three button factories, one per custom-id shape (see ChoiceButtonCustomId
|
||||
// comment above). RenderX() uses these to build rows; the call site
|
||||
// determines which kind of button each row needs.
|
||||
private static ButtonProperties ChoiceBtn(string label, string step, string value, ButtonStyle style = ButtonStyle.Secondary)
|
||||
{
|
||||
var cid = ChoiceButtonCustomId(step, value);
|
||||
EnsureCustomIdFits(cid);
|
||||
return new ButtonProperties(cid, label, style);
|
||||
}
|
||||
|
||||
private static ButtonProperties ControlBtn(string label, string action, ButtonStyle style = ButtonStyle.Secondary)
|
||||
{
|
||||
var cid = ControlButtonCustomId(action);
|
||||
EnsureCustomIdFits(cid);
|
||||
return new ButtonProperties(cid, label, style);
|
||||
}
|
||||
|
||||
private static ButtonProperties ModalTriggerBtn(string label, string modalStep, ButtonStyle style = ButtonStyle.Secondary)
|
||||
{
|
||||
var cid = ModalTriggerButtonCustomId(modalStep);
|
||||
EnsureCustomIdFits(cid);
|
||||
return new ButtonProperties(cid, label, style);
|
||||
}
|
||||
|
||||
private static ActionRowProperties Row(params IActionRowComponentProperties[] components)
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var c in components)
|
||||
{
|
||||
row.Add(c);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a list of top-level message components (action rows and
|
||||
/// select menus) into a single <see cref="IReadOnlyList{IMessageComponentProperties}"/>
|
||||
/// for <c>MessageProperties.WithComponents</c>.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<IMessageComponentProperties> Comps(params IMessageComponentProperties[] items) =>
|
||||
items;
|
||||
|
||||
private static void EnsureCustomIdFits(string customId)
|
||||
{
|
||||
if (customId.Length > MaxCustomIdLength)
|
||||
{
|
||||
throw new System.InvalidOperationException(
|
||||
$"Custom id '{customId}' is {customId.Length} chars; Discord limit is {MaxCustomIdLength}.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single-game steps ─────────────────────────────────────────────
|
||||
private static DiscordWizardRender RenderType() => new(
|
||||
"🎲 Создание игровой сессии",
|
||||
"Выберите тип: одна игра или пул.",
|
||||
new IMessageComponentProperties[] { Row(ChoiceBtn("🎯 Одну игру", WizardStepNames.Type, "single", ButtonStyle.Primary),
|
||||
ChoiceBtn("📅 Пул игр", WizardStepNames.Type, "pool", ButtonStyle.Primary),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderTitle() => new(
|
||||
"📝 Название",
|
||||
"Введите название игры в модальном окне.",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Title);
|
||||
|
||||
private static DiscordWizardRender RenderDescription() => new(
|
||||
"📄 Описание",
|
||||
"Введите описание (или «-», чтобы пропустить).",
|
||||
new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Description, "-"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Description);
|
||||
|
||||
private static DiscordWizardRender RenderCover() => new(
|
||||
"🖼 Обложка",
|
||||
"Введите URL картинки (или «-», чтобы пропустить).",
|
||||
new IMessageComponentProperties[] { Row(ChoiceBtn("⏭ Пропустить", WizardStepNames.Cover, "-"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.Cover);
|
||||
|
||||
private static DiscordWizardRender RenderSystem() => new(
|
||||
"🎲 Система",
|
||||
"Выберите систему.",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("D&D 5e", WizardStepNames.System, "Dnd5e"),
|
||||
ChoiceBtn("Pathfinder 2e", WizardStepNames.System, "Pathfinder2e"),
|
||||
ChoiceBtn("Call of Cthulhu", WizardStepNames.System, "CallOfCthulhu7e"),
|
||||
ChoiceBtn("GURPS", WizardStepNames.System, "GURPS"),
|
||||
ChoiceBtn("Fate", WizardStepNames.System, "Fate")),
|
||||
Row(ModalTriggerBtn("Другое… ✏️", "SystemFreeText", ButtonStyle.Primary),
|
||||
ChoiceBtn("⏭ Пропустить", WizardStepNames.System, "_skip"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderDuration() => new(
|
||||
"⏱ Длительность",
|
||||
"Выберите длительность (или «Другое…»).",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("3 часа", WizardStepNames.Duration, "180"),
|
||||
ChoiceBtn("4 часа", WizardStepNames.Duration, "240"),
|
||||
ChoiceBtn("5 часов", WizardStepNames.Duration, "300"),
|
||||
ChoiceBtn("6 часов", WizardStepNames.Duration, "360")),
|
||||
Row(ModalTriggerBtn("Другое… ✏️", "DurationFreeText", ButtonStyle.Primary),
|
||||
ChoiceBtn("⏭ Пропустить", WizardStepNames.Duration, "_skip"),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderDateTime() => new(
|
||||
"📅 Дата и время",
|
||||
"Введите дату в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.DateTime);
|
||||
|
||||
private static DiscordWizardRender RenderCapacity() => new(
|
||||
"👥 Лимит мест",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: WizardStepNames.Capacity);
|
||||
|
||||
private static DiscordWizardRender RenderVisibility() => new(
|
||||
"🔒 Видимость",
|
||||
"Выберите, кто увидит сессию.",
|
||||
new IMessageComponentProperties[]
|
||||
{
|
||||
BuildSelectMenu(
|
||||
SelectCustomId(WizardStepNames.Visibility),
|
||||
"Выберите видимость…",
|
||||
new[] { new StringMenuSelectOptionProperties("🌐 Публичная в общем showcase", "public"),
|
||||
new StringMenuSelectOptionProperties("🏠 Публичная в витрине клуба", "club"),
|
||||
new StringMenuSelectOptionProperties("🔐 Только для членов клуба", "members"),
|
||||
}),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
|
||||
{
|
||||
if (clubs.Count == 0)
|
||||
{
|
||||
return new DiscordWizardRender(
|
||||
"🏷 Выбор клуба",
|
||||
"У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: null);
|
||||
}
|
||||
var options = new List<StringMenuSelectOptionProperties>(clubs.Count);
|
||||
foreach (var c in clubs.Take(25))
|
||||
{
|
||||
options.Add(new StringMenuSelectOptionProperties(c.Name, c.ClubId.ToString()));
|
||||
}
|
||||
return new DiscordWizardRender(
|
||||
"🏷 Выбор клуба",
|
||||
"Выберите клуб из списка.",
|
||||
new IMessageComponentProperties[]
|
||||
{
|
||||
BuildSelectMenu(SelectCustomId(WizardStepNames.PickClub), "Выберите клуб…", options),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
}
|
||||
|
||||
private static DiscordWizardRender RenderPublish() => new(
|
||||
"✨ Публикация",
|
||||
"Опубликовать в витрине сейчас?",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Опубликовать", WizardStepNames.Publish, "yes", ButtonStyle.Success),
|
||||
ChoiceBtn("📝 Только в чате", WizardStepNames.Publish, "no")),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderConfirm(WizardPayload p) => new(
|
||||
"👀 Проверьте перед созданием",
|
||||
BuildConfirmDescription(p),
|
||||
new[]
|
||||
{
|
||||
Row(ControlBtn("✅ Создать", "create", ButtonStyle.Success),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
// ── Pool steps ────────────────────────────────────────────────────
|
||||
private static DiscordWizardRender RenderPoolSystemDuration() => new(
|
||||
"🎲 Система и длительность пула",
|
||||
"Выберите пресет или «Другое…».",
|
||||
new IMessageComponentProperties[]
|
||||
{
|
||||
BuildSelectMenu(
|
||||
SelectCustomId(WizardStepNames.PoolSystemDuration),
|
||||
"Выберите пресет…",
|
||||
new[] { new StringMenuSelectOptionProperties("D&D 5e · 4 ч", "Dnd5e:240"),
|
||||
new StringMenuSelectOptionProperties("Pathfinder 2e · 4 ч", "Pathfinder2e:240"),
|
||||
new StringMenuSelectOptionProperties("Call of Cthulhu · 3 ч", "CallOfCthulhu7e:180"),
|
||||
new StringMenuSelectOptionProperties("GURPS · 4 ч", "GURPS:240"),
|
||||
}),
|
||||
Row(ModalTriggerBtn("Другое… ✏️", "PoolSystemDurationFreeText", ButtonStyle.Primary),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderPoolAddSlots(WizardPayload p) => new(
|
||||
$"📅 Слоты пула «{p.Title}»",
|
||||
$"Добавлено: {p.Pool?.Slots.Count ?? 0}.",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("➕ Добавить слот", WizardStepNames.PoolAddSlots, "add", ButtonStyle.Primary),
|
||||
ChoiceBtn("✅ Готово, к превью", WizardStepNames.PoolAddSlots, "done", ButtonStyle.Success)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
private static DiscordWizardRender RenderPoolSlotDateTime() => new(
|
||||
"📅 Дата/время слота",
|
||||
"Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
||||
new IMessageComponentProperties[] { Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)) },
|
||||
OpenModalStep: WizardStepNames.PoolSlotDateTime);
|
||||
|
||||
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
||||
"👥 Лимит слотов",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: WizardStepNames.PoolSlotCapacity);
|
||||
|
||||
private static DiscordWizardRender RenderPoolConfirm(WizardPayload p) => new(
|
||||
"👀 Проверьте пул перед созданием",
|
||||
BuildPoolConfirmDescription(p),
|
||||
new[]
|
||||
{
|
||||
Row(ControlBtn("✅ Создать пул", "create", ButtonStyle.Success),
|
||||
ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
OpenModalStep: null);
|
||||
|
||||
// ── Builders for embed descriptions and selects ───────────────────
|
||||
private static StringMenuProperties BuildSelectMenu(
|
||||
string customId,
|
||||
string placeholder,
|
||||
IReadOnlyList<StringMenuSelectOptionProperties> options)
|
||||
{
|
||||
EnsureCustomIdFits(customId);
|
||||
return new StringMenuProperties(customId, options)
|
||||
{
|
||||
Placeholder = placeholder,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildConfirmDescription(WizardPayload p)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("🎲 ").AppendLine(p.Title);
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
|
||||
if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
|
||||
if (p.Single?.ScheduledAt is { } at) sb.Append("📅 ").AppendLine(at.FormatMoscow());
|
||||
if (p.Single?.MaxPlayers is { } mp)
|
||||
{
|
||||
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
else if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildPoolConfirmDescription(WizardPayload p)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("📝 ").AppendLine(p.Title);
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.Append("📄 ").AppendLine(p.Description);
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.Append("🎲 Система: ").AppendLine(p.System);
|
||||
if (p.DurationMinutes.HasValue) sb.Append("⏱ Длительность: ").Append(p.DurationMinutes.Value / 60).AppendLine(" ч");
|
||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||
sb.Append("Слоты (").Append(p.Pool?.Slots.Count ?? 0).AppendLine("):");
|
||||
if (p.Pool is not null)
|
||||
{
|
||||
foreach (var s in p.Pool.Slots)
|
||||
{
|
||||
sb.Append(" • ").Append(s.ScheduledAt.FormatMoscow())
|
||||
.Append(" — мест ").Append(s.MaxPlayers)
|
||||
.Append(", waitlist ").Append(s.Waitlist ? "вкл" : "выкл")
|
||||
.AppendLine();
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
||||
{
|
||||
WizardVisibility.Public => "публичная в общем showcase",
|
||||
WizardVisibility.Club => "публичная в витрине клуба",
|
||||
WizardVisibility.Members => "только для членов клуба",
|
||||
_ => "не задана",
|
||||
};
|
||||
|
||||
// ── Modal builders ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Build a <see cref="ModalProperties"/> for the given wizard step.
|
||||
/// The wizard step's <c>openModal</c> value drives which modal we
|
||||
/// emit. Returns <c>null</c> if no modal is required for the step.
|
||||
/// </summary>
|
||||
public static ModalProperties? BuildModal(string step, string? draftTitle)
|
||||
{
|
||||
return step switch
|
||||
{
|
||||
WizardStepNames.Title => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Title),
|
||||
"📝 Название игры",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Название",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Title), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "Например: D&D 5e, Проклятие Страда",
|
||||
MinLength = 1,
|
||||
MaxLength = WizardStepLimits.MaxTitleLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Description => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Description),
|
||||
"📄 Описание",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Описание",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Description), TextInputStyle.Paragraph)
|
||||
{
|
||||
Placeholder = "Опишите сценарий / сеттинг. «-» чтобы пропустить.",
|
||||
MaxLength = WizardStepLimits.MaxDescriptionLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Cover => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Cover),
|
||||
"🖼 Обложка (URL)",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"URL картинки",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Cover), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "https://… или «-» чтобы пропустить",
|
||||
MaxLength = 500,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
"SystemFreeText" => new ModalProperties(
|
||||
ModalCustomId("SystemFreeText"),
|
||||
"🎲 Другая система",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Система",
|
||||
new TextInputProperties(ModalCustomId("SystemFreeText"), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "Свободное название системы",
|
||||
MaxLength = WizardStepLimits.MaxSystemLength,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
"DurationFreeText" => new ModalProperties(
|
||||
ModalCustomId("DurationFreeText"),
|
||||
"⏱ Длительность (часы)",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Часы",
|
||||
new TextInputProperties(ModalCustomId("DurationFreeText"), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "1..12",
|
||||
MaxLength = 4,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.DateTime => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.DateTime),
|
||||
"📅 Дата и время",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Когда",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.DateTime), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
|
||||
MaxLength = 32,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.Capacity => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.Capacity),
|
||||
"👥 Лимит мест",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Max players",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.Capacity), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "1..50",
|
||||
MaxLength = 3,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.PoolSlotDateTime => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.PoolSlotDateTime),
|
||||
"📅 Дата/время слота",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Когда",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotDateTime), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "ДД.ММ.ГГГГ ЧЧ:ММ",
|
||||
MaxLength = 32,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
WizardStepNames.PoolSlotCapacity => new ModalProperties(
|
||||
ModalCustomId(WizardStepNames.PoolSlotCapacity),
|
||||
"👥 Лимит слотов",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Max players",
|
||||
new TextInputProperties(ModalCustomId(WizardStepNames.PoolSlotCapacity), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "1..50",
|
||||
MaxLength = 3,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
"PoolSystemDurationFreeText" => new ModalProperties(
|
||||
ModalCustomId("PoolSystemDurationFreeText"),
|
||||
"🎲 Другая система пула",
|
||||
new IModalComponentProperties[]
|
||||
{
|
||||
new LabelProperties(
|
||||
"Система и длительность",
|
||||
new TextInputProperties(ModalCustomId("PoolSystemDurationFreeText"), TextInputStyle.Short)
|
||||
{
|
||||
Placeholder = "Dnd5e:240 или «Pathfinder 2e:180»",
|
||||
MaxLength = 32,
|
||||
Required = true,
|
||||
}),
|
||||
}),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Finalises a wizard draft by calling the shared
|
||||
/// <see cref="CreateSessionHandler"/>. On success the original draft
|
||||
/// message is overwritten with a "✅ Создано" confirmation; on failure
|
||||
/// the user is offered Retry / Cancel buttons so the same draft can be
|
||||
/// re-submitted without re-entering the wizard from scratch.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardSubmitter
|
||||
{
|
||||
private const int MaxRetries = 3;
|
||||
|
||||
private readonly CreateSessionHandler _shared;
|
||||
private readonly RestClient _rest;
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardContextStore _contextStore;
|
||||
private readonly ILogger<DiscordWizardSubmitter> _log;
|
||||
|
||||
public DiscordWizardSubmitter(
|
||||
CreateSessionHandler shared,
|
||||
RestClient rest,
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardContextStore contextStore,
|
||||
ILogger<DiscordWizardSubmitter> log)
|
||||
{
|
||||
_shared = shared;
|
||||
_rest = rest;
|
||||
_drafts = drafts;
|
||||
_contextStore = contextStore;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submit the draft to the shared handler. On a 1-shot failure we
|
||||
/// edit the draft message to show "retry / cancel" affordances and
|
||||
/// bump the in-payload retry counter; after <see cref="MaxRetries"/>
|
||||
/// consecutive failures the draft is deleted.
|
||||
/// </summary>
|
||||
public async Task SubmitAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
var payload = LoadPayload(draft);
|
||||
if (!IsComplete(payload, out var missing))
|
||||
{
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"❌ Не заполнены поля: {missing}",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var commands = BuildCommands(draft, payload);
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Submit failed for draft {DraftId}", draft.Id);
|
||||
payload.RetryCount += 1;
|
||||
SavePayload(draft, payload);
|
||||
if (payload.RetryCount >= MaxRetries)
|
||||
{
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession-wizard, чтобы начать заново.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
_contextStore.Remove(draft.Id);
|
||||
return;
|
||||
}
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
// The full exception (with stack trace, Postgres constraint
|
||||
// name, sometimes partial SQL) is already logged server-side
|
||||
// on line 86. Show the user a generic message — never leak
|
||||
// internal error strings to the Discord channel.
|
||||
await EditDraftMessageAsync(
|
||||
draft,
|
||||
$"💥 Не удалось создать сессию. Попробуйте ещё раз (попытка {payload.RetryCount}/{MaxRetries}).",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build shared commands ────────────────────────────────────────
|
||||
// Same shape as the Telegram submitter: pool → one command with N
|
||||
// times, single → one command with one time.
|
||||
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||||
{
|
||||
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||
{
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||
MaxPlayersForPool(pool),
|
||||
isOneShot: false),
|
||||
};
|
||||
}
|
||||
return new List<CreateSessionCommand>
|
||||
{
|
||||
BuildCommand(
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
PlatformKind.Discord,
|
||||
draft.OwnerId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalUsername: null);
|
||||
var group = new PlatformGroup(
|
||||
PlatformKind.Discord,
|
||||
draft.ChatId,
|
||||
DisplayName: string.Empty,
|
||||
ExternalChannelId: null,
|
||||
ExternalThreadId: draft.MessageThreadId);
|
||||
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 System.Text.Json.JsonSerializer.Deserialize(
|
||||
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||
}
|
||||
|
||||
private static void SavePayload(WizardDraft draft, WizardPayload p)
|
||||
{
|
||||
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
p, WizardPayloadJsonContext.Default.WizardPayload);
|
||||
}
|
||||
|
||||
// ── Embed editing ────────────────────────────────────────────────
|
||||
private async Task EditDraftMessageAsync(
|
||||
WizardDraft draft, string text, IReadOnlyList<WizardAction> actions, CancellationToken ct)
|
||||
{
|
||||
if (!_contextStore.TryGet(draft.Id, out var ctx))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ulong.TryParse(ctx.ChannelId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) ||
|
||||
!ulong.TryParse(ctx.MessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle("Мастер создания сессии")
|
||||
.WithDescription(Truncate(text, 3900))
|
||||
.WithColor(new Color(0x5865F2));
|
||||
var rows = BuildActionRowsFromActions(actions);
|
||||
await _rest.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = new[] { embed };
|
||||
options.Components = rows;
|
||||
});
|
||||
}
|
||||
catch (RestException ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Failed to edit wizard message for draft {DraftId}", draft.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IMessageComponentProperties> BuildActionRowsFromActions(
|
||||
IReadOnlyList<WizardAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return Array.Empty<IMessageComponentProperties>();
|
||||
}
|
||||
var rows = new List<IMessageComponentProperties>();
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
var style = action.Style switch
|
||||
{
|
||||
WizardActionStyle.Primary => ButtonStyle.Primary,
|
||||
WizardActionStyle.Success => ButtonStyle.Success,
|
||||
WizardActionStyle.Danger => ButtonStyle.Danger,
|
||||
_ => ButtonStyle.Secondary,
|
||||
};
|
||||
row.Add(new ButtonProperties(action.Payload, action.Label, style));
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int max) =>
|
||||
text.Length <= max ? text : text[..max];
|
||||
|
||||
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||
{
|
||||
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.DiscordBot.Infrastructure;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Health;
|
||||
@@ -10,6 +11,7 @@ using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
@@ -25,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
|
||||
[module: Dapper.DapperAot]
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
@@ -82,6 +86,23 @@ builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
|
||||
|
||||
// ── Wizard services (issue #112) ──────────────────────────────────────
|
||||
// The Discord wizard reuses the platform-neutral state machine in
|
||||
// GmRelay.Shared (GameCreationWizard, IWizardMessenger,
|
||||
// IWizardDraftRepository) and only adds a Discord-specific messenger,
|
||||
// step renderer, slash command, and submitter on top. The wizard's
|
||||
// cleanup service is shared with the Telegram bot and is not
|
||||
// registered here — it would compete on the same drafts table.
|
||||
builder.Services.AddSingleton<IWizardDraftRepository, GmRelay.Shared.Features.Sessions.CreateSession.Wizard.WizardDraftRepository>();
|
||||
builder.Services.AddSingleton<IWizardContextStore, DiscordWizardContextStore>();
|
||||
builder.Services.AddSingleton<IWizardMessenger, DiscordWizardMessenger>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||
builder.Services.AddSingleton<DiscordWizardSubmitter>();
|
||||
builder.Services.AddSingleton<WizardInteractionDispatcher>();
|
||||
builder.Services.AddSingleton<DiscordWizardButtonModule>();
|
||||
builder.Services.AddSingleton<DiscordWizardStringMenuModule>();
|
||||
builder.Services.AddSingleton<DiscordWizardModalModule>();
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
{
|
||||
@@ -90,6 +111,8 @@ builder.Services
|
||||
})
|
||||
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||
.AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>()
|
||||
.AddComponentInteractions<ModalInteraction, ModalInteractionContext>()
|
||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||
@@ -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,540 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Central state machine for the game/pool creation wizard. Lives in
|
||||
/// <c>GmRelay.Shared</c> so it can be driven from any platform
|
||||
/// messenger. Platform-specific code (<c>Telegram.Bot</c>,
|
||||
/// <c>NetCord</c>, …) lives in the corresponding adapter and converts
|
||||
/// its native update type into a <see cref="WizardInteraction"/> before
|
||||
/// calling <see cref="HandleInteractionAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class GameCreationWizard
|
||||
{
|
||||
private readonly IWizardDraftRepository _drafts;
|
||||
private readonly IWizardMessenger _messenger;
|
||||
private readonly ILogger<GameCreationWizard> _log;
|
||||
|
||||
public GameCreationWizard(
|
||||
IWizardDraftRepository drafts,
|
||||
IWizardMessenger messenger,
|
||||
ILogger<GameCreationWizard> log)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_messenger = messenger;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a single user interaction with the wizard. Adapters should
|
||||
/// map their native event (Telegram <c>Update</c>, Discord
|
||||
/// interaction, …) into a <see cref="WizardInteraction"/> first.
|
||||
/// </summary>
|
||||
public async Task HandleInteractionAsync(
|
||||
WizardInteraction interaction,
|
||||
WizardDraft draft,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (interaction.CallbackPayload is not null)
|
||||
{
|
||||
await HandleCallbackAsync(draft, interaction, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleTextAsync(draft, interaction, ct);
|
||||
}
|
||||
}
|
||||
catch (WizardStorageException)
|
||||
{
|
||||
if (interaction.CallbackPayload is not null)
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(
|
||||
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
|
||||
if (interaction.CallbackPayload is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(
|
||||
interaction.InteractionId, "⚠️ Ошибка", ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* swallow — we're already in error path */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCallbackAsync(
|
||||
WizardDraft draft,
|
||||
WizardInteraction interaction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice))
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "cancel":
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft, "❌ Мастер отменён.", Array.Empty<WizardAction>(), ct);
|
||||
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||
return;
|
||||
|
||||
case "back":
|
||||
ApplyBack(draft, step);
|
||||
await PersistAndRenderAsync(draft, interaction.InteractionId, ct);
|
||||
return;
|
||||
|
||||
case "create":
|
||||
// Routed by the platform's CreateSessionHandler, not here.
|
||||
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
|
||||
return;
|
||||
|
||||
default:
|
||||
// For "Choice" callbacks, action == step.
|
||||
await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTextAsync(
|
||||
WizardDraft draft,
|
||||
WizardInteraction interaction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (interaction.Text is not { } text)
|
||||
{
|
||||
// Photo or other non-text — handle cover step only.
|
||||
if (interaction.PhotoFileId is { } fileId &&
|
||||
draft.Step == WizardStepNames.Cover)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// Re-render the same step with ⚠️ prefix.
|
||||
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, 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 interactionId, CancellationToken ct)
|
||||
{
|
||||
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
|
||||
if (error is { } err)
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(interactionId, err, ct);
|
||||
return;
|
||||
}
|
||||
if (payload is { } p) SavePayload(draft, p);
|
||||
if (nextStep is { } s)
|
||||
{
|
||||
draft.Step = s;
|
||||
}
|
||||
await PersistAndRenderAsync(draft, interactionId, ct);
|
||||
}
|
||||
|
||||
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
||||
{
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
var payload = LoadPayload(draft);
|
||||
IReadOnlyList<WizardClubOption>? clubs = null;
|
||||
if (draft.Step == WizardStepNames.PickClub)
|
||||
{
|
||||
clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct);
|
||||
}
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
|
||||
if (interactionId is { } id)
|
||||
{
|
||||
await _messenger.AnswerInteractionAsync(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, WizardStepLimits.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, WizardStepLimits.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, WizardStepLimits.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 >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
||||
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
||||
: (null, "Лимит должен быть 1..50", payload);
|
||||
|
||||
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
||||
return ValidateText(input, WizardStepLimits.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 >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.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)
|
||||
{
|
||||
if (choice is "no_limit")
|
||||
{
|
||||
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
|
||||
}
|
||||
|
||||
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
|
||||
{
|
||||
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||
}
|
||||
|
||||
return 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)
|
||||
{
|
||||
if (!Guid.TryParse(choice, out var id))
|
||||
{
|
||||
return (null, "Неверный идентификатор клуба");
|
||||
}
|
||||
|
||||
var error = SetClubId(p, id);
|
||||
return (NextAfterVisibility(p), error);
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────
|
||||
public 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 < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
|
||||
minutes = (int)Math.Round(hours * 60);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Visual style for a wizard button. The platform adapter maps this to its
|
||||
/// own native styling (Telegram currently ignores it; Discord uses it for
|
||||
/// primary/danger/success button colors).
|
||||
/// </summary>
|
||||
public enum WizardActionStyle
|
||||
{
|
||||
Primary,
|
||||
Secondary,
|
||||
Success,
|
||||
Danger,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single button on a wizard keyboard. <see cref="Payload"/> is the
|
||||
/// platform-neutral callback token — usually produced by
|
||||
/// <see cref="WizardCallbackData"/> but adapters are free to interpret
|
||||
/// any string.
|
||||
/// </summary>
|
||||
public sealed record WizardAction(
|
||||
string Label,
|
||||
string Payload,
|
||||
WizardActionStyle Style = WizardActionStyle.Secondary);
|
||||
|
||||
/// <summary>
|
||||
/// One row of buttons on a wizard keyboard. The platform adapter is
|
||||
/// responsible for laying out rows; the wizard core returns a flat list
|
||||
/// of actions and trusts the adapter to split them into rows.
|
||||
/// </summary>
|
||||
public sealed record WizardKeyboard(IReadOnlyList<WizardAction> Actions);
|
||||
|
||||
/// <summary>
|
||||
/// A user-owned group/club selectable from the visibility step. Moved
|
||||
/// from <c>GmRelay.Bot</c> so the wizard can ask for the list without
|
||||
/// taking a dependency on Telegram.
|
||||
/// </summary>
|
||||
public sealed record WizardClubOption(Guid ClubId, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Platform-neutral user interaction with the wizard. Adapters convert
|
||||
/// their native event (Telegram <c>Update</c>, Discord interaction, …)
|
||||
/// into one of these before handing it to <see cref="GameCreationWizard"/>.
|
||||
/// </summary>
|
||||
public sealed record WizardInteraction(
|
||||
string OwnerId,
|
||||
string? Text,
|
||||
string? CallbackPayload,
|
||||
string? PhotoFileId,
|
||||
string? PhotoUrl,
|
||||
string InteractionId);
|
||||
|
||||
/// <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(string platform, string ownerId, CancellationToken ct);
|
||||
|
||||
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken ct);
|
||||
|
||||
Task<int> DeleteExpiredAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contract the wizard core uses to talk to the chat platform. Each
|
||||
/// platform supplies its own implementation (Telegram today, Discord in
|
||||
/// a follow-up task).
|
||||
/// </summary>
|
||||
public interface IWizardMessenger
|
||||
{
|
||||
/// <summary>
|
||||
/// Edit the message that currently represents the wizard draft.
|
||||
/// Returns the new message id as a string — Telegram exposes
|
||||
/// <c>int32</c>, Discord uses 64-bit snowflakes, both fit in
|
||||
/// <see cref="string"/> for cross-platform uniformity.
|
||||
/// </summary>
|
||||
Task<string> EditDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Post a fresh wizard draft message and return its id.
|
||||
/// </summary>
|
||||
Task<string> SendDraftMessageAsync(
|
||||
WizardDraft draft,
|
||||
string text,
|
||||
IReadOnlyList<WizardAction> keyboard,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a callback / interaction. <paramref name="text"/>
|
||||
/// is an optional toast the user sees briefly.
|
||||
/// </summary>
|
||||
Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// List the clubs/groups the owner manages. The platform
|
||||
/// implementation decides how to query the database — the wizard
|
||||
/// core only needs a list of (id, name) pairs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format for wizard callback data. The format is shared by all
|
||||
/// platforms (Telegram today, Discord in a follow-up task) and must
|
||||
/// stay stable because it is persisted in chat histories and slash-command
|
||||
/// autocomplete. Token is <c>wizard</c> to keep the namespace separate
|
||||
/// from the rest of the bot's command callbacks.
|
||||
/// </summary>
|
||||
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,51 @@
|
||||
using System;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
public sealed class WizardDraft
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable string id of the chat/guild/channel this draft lives in.
|
||||
/// Stored as <c>TEXT</c> to fit both Telegram's <c>long</c> chat ids
|
||||
/// and Discord's snowflakes.
|
||||
/// </summary>
|
||||
public string ChatId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional thread/topic id within the chat. Telegram's
|
||||
/// <c>message_thread_id</c>, Discord's thread snowflake, <c>null</c>
|
||||
/// when the chat has no sub-thread concept.
|
||||
/// </summary>
|
||||
public string? MessageThreadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-specific user id of the wizard owner. Telegram uses
|
||||
/// <c>long</c>, Discord uses snowflakes — both fit in a string.
|
||||
/// </summary>
|
||||
public string OwnerId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Which messenger platform owns this draft. Defaults to
|
||||
/// <c>"Telegram"</c> for backward compatibility with pre-V032 rows.
|
||||
/// </summary>
|
||||
public string Platform { get; set; } = "Telegram";
|
||||
|
||||
public string Step { get; set; } = string.Empty;
|
||||
|
||||
public string PayloadJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Id of the message that the wizard last edited. Stored as
|
||||
/// <c>TEXT</c> to fit both Telegram's <c>int32</c> ids and Discord's
|
||||
/// 64-bit snowflakes.
|
||||
/// </summary>
|
||||
public string? DraftMessageId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
{
|
||||
// NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the
|
||||
// (sql, param) extension overloads, NOT for the (CommandDefinition) overload.
|
||||
// Passing a CommandDefinition here would skip the interceptor and fall back to
|
||||
// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
|
||||
// throws PlatformNotSupportedException on AOT (issue: wizard silently dropped
|
||||
// every Telegram update in v3.9.0). All four methods therefore use the plain
|
||||
// (sql, param) overload, matching the pattern in JoinSessionHandler.
|
||||
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id AS Id,
|
||||
chat_id AS ChatId,
|
||||
message_thread_id AS MessageThreadId,
|
||||
owner_id AS OwnerId,
|
||||
platform AS Platform,
|
||||
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 platform = @Platform
|
||||
AND owner_id = @OwnerId
|
||||
AND expires_at > NOW()
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
||||
sql,
|
||||
new { Platform = platform, OwnerId = ownerId });
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO wizard_drafts
|
||||
(id, chat_id, message_thread_id, owner_id, platform, step, payload, draft_message_id, created_at, updated_at, expires_at)
|
||||
VALUES
|
||||
(@Id, @ChatId, @MessageThreadId, @OwnerId, @Platform, @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(sql, draft);
|
||||
}
|
||||
|
||||
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(sql, new { Id = id });
|
||||
}
|
||||
|
||||
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(sql);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Limits and bounds used by the wizard's input validation. Kept here
|
||||
/// (rather than on the Telegram-only <c>WizardStep</c>) so the state
|
||||
/// machine can reference them without pulling in a platform dependency.
|
||||
/// </summary>
|
||||
public static class WizardStepLimits
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Symbolic step identifiers used by <see cref="WizardDraft.Step"/> and
|
||||
/// the <see cref="WizardCallbackData"/> payload. Strings (rather than an
|
||||
/// enum) so that future platforms can extend the set without breaking
|
||||
/// the wire format stored in PostgreSQL.
|
||||
/// </summary>
|
||||
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,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a (text, list of <see cref="WizardAction"/>s) pair for each
|
||||
/// wizard step. This is the "view builder" half of ADR-002: the same
|
||||
/// builder is used by every platform messenger, and each messenger is
|
||||
/// responsible for converting the action list into its native UI
|
||||
/// (Telegram's <c>InlineKeyboardMarkup</c> today, Discord components
|
||||
/// later).
|
||||
/// </summary>
|
||||
public static class WizardStepViewBuilder
|
||||
{
|
||||
public static (string Text, IReadOnlyList<WizardAction> Actions) Build(
|
||||
WizardDraft draft,
|
||||
WizardPayload payload,
|
||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||
{
|
||||
return draft.Step switch
|
||||
{
|
||||
WizardStepNames.Type => BuildType(),
|
||||
WizardStepNames.Title => BuildTitle(),
|
||||
WizardStepNames.Description => BuildDescription(),
|
||||
WizardStepNames.Cover => BuildCover(),
|
||||
WizardStepNames.System => BuildSystem(),
|
||||
WizardStepNames.Duration => BuildDuration(),
|
||||
WizardStepNames.DateTime => BuildDateTime(),
|
||||
WizardStepNames.Capacity => BuildCapacity(),
|
||||
WizardStepNames.Visibility => BuildVisibility(),
|
||||
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => BuildPublish(),
|
||||
WizardStepNames.Confirm => BuildSingleConfirm(payload),
|
||||
|
||||
WizardStepNames.PoolSystemDuration => BuildPoolSystemDuration(),
|
||||
WizardStepNames.PoolAddSlots => BuildPoolAddSlots(payload),
|
||||
WizardStepNames.PoolSlotDateTime => BuildPoolSlotDateTime(),
|
||||
WizardStepNames.PoolSlotCapacity => BuildPoolSlotCapacity(),
|
||||
WizardStepNames.PoolConfirm => BuildPoolConfirm(payload),
|
||||
|
||||
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Single-game views ──────────────────────────────────────────────
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildType() => (
|
||||
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
|
||||
new[]
|
||||
{
|
||||
new WizardAction("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single"), WizardActionStyle.Primary),
|
||||
new WizardAction("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool"), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildTitle() => (
|
||||
"📝 Введите название игры одним сообщением.",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDescription() => (
|
||||
"📄 Введите описание (или «-», чтобы пропустить).",
|
||||
SkipBackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCover() => (
|
||||
"🖼 Пришлите картинку как вложение или URL (или «-»).",
|
||||
SkipBackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildSystem() => (
|
||||
"🎲 Выберите систему.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")),
|
||||
new("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")),
|
||||
new("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")),
|
||||
new("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")),
|
||||
new("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")),
|
||||
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDuration() => (
|
||||
"⏱ Выберите длительность.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")),
|
||||
new("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")),
|
||||
new("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")),
|
||||
new("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")),
|
||||
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDateTime() => (
|
||||
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||
"🔒 Выберите видимость.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public"), WizardActionStyle.Primary),
|
||||
new("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club"), WizardActionStyle.Primary),
|
||||
new("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")),
|
||||
new("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPickClub(IReadOnlyList<WizardClubOption> clubs)
|
||||
{
|
||||
if (clubs.Count == 0)
|
||||
{
|
||||
return (
|
||||
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
||||
BackCancel());
|
||||
}
|
||||
var actions = new List<WizardAction>(clubs.Count);
|
||||
foreach (var club in clubs)
|
||||
{
|
||||
actions.Add(new WizardAction(
|
||||
club.Name,
|
||||
WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString())));
|
||||
}
|
||||
return ("🏷 Выберите клуб:", actions);
|
||||
}
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPublish() => (
|
||||
"✨ Опубликовать в витрине сейчас?",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success),
|
||||
new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildSingleConfirm(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 List<WizardAction>
|
||||
{
|
||||
new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pool views ─────────────────────────────────────────────────────
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSystemDuration() => (
|
||||
"🎲 Выберите систему и длительность пула.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")),
|
||||
new("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")),
|
||||
new("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")),
|
||||
new("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolAddSlots(WizardPayload p) => (
|
||||
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary),
|
||||
new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotDateTime() => (
|
||||
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) и выберите waitlist.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolConfirm(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 List<WizardAction>
|
||||
{
|
||||
new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
private static IReadOnlyList<WizardAction> BackCancel() => new[]
|
||||
{
|
||||
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<WizardAction> SkipBackCancel() => new[]
|
||||
{
|
||||
new WizardAction("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")),
|
||||
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
|
||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
||||
{
|
||||
WizardVisibility.Public => "публичная в общем showcase",
|
||||
WizardVisibility.Club => "публичная в витрине клуба",
|
||||
WizardVisibility.Members => "только для членов клуба",
|
||||
_ => "не задана",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the wizard's persistence layer fails. The wizard catches
|
||||
/// this specifically so the user sees a friendly message instead of a
|
||||
/// raw stack trace.
|
||||
/// </summary>
|
||||
public sealed class WizardStorageException : Exception
|
||||
{
|
||||
public WizardStorageException(string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,12 @@ public sealed class DeleteSessionHandler(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Fetch session and verify group manager.
|
||||
// 1. Use the database mutation order before locking the session or linked portfolio cards.
|
||||
await connection.ExecuteAsync(
|
||||
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||
transaction: transaction);
|
||||
|
||||
// 2. Lock the session before any linked portfolio card and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
@@ -49,6 +54,7 @@ public sealed class DeleteSessionHandler(
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE OF s
|
||||
""",
|
||||
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||
|
||||
@@ -62,7 +68,21 @@ public sealed class DeleteSessionHandler(
|
||||
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
// 2. Delete session
|
||||
// 3. Unpublish a linked portfolio card before its required session link cascades away.
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
FROM portfolio_game_sessions pgs
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND pgs.session_id = @SessionId
|
||||
AND pg.is_public = true
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
// 4. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
|
||||
@@ -18,5 +18,7 @@ public sealed record ShowcaseSessionDto(
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string PublicationMode = "None",
|
||||
bool IsMembersOnly = false,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
</svg>
|
||||
Профиль
|
||||
</NavLink>
|
||||
|
||||
<NavLink class="nav-item" href="profile/memberships" @onclick="CloseMenu">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18"/>
|
||||
<path d="M5 21V7l8-4v18"/>
|
||||
<path d="M19 21V11l-6-4"/>
|
||||
</svg>
|
||||
Мои клубы
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer">
|
||||
@@ -73,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.5.0</div>
|
||||
<div class="nav-version">v3.9.6</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<img src="/logo.png" alt="GM-Relay" />
|
||||
<span>GM-Relay</span>
|
||||
</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
<div class="public-topbar-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="/showcase">Клубы</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="public-content">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
@page "/group/{GroupId:guid}/applications"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@using System.Security.Claims
|
||||
|
||||
<PageTitle>Заявки участников — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="@($"/group/{GroupId}")">Группа</a></li>
|
||||
<li class="active">Заявки</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📨 Заявки участников</h2>
|
||||
<p>Одобряйте или отклоняйте заявки на участие в клубе.</p>
|
||||
</div>
|
||||
|
||||
@if (accessDenied)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Нет доступа</h2>
|
||||
<p>Только owner или co-GM группы могут просматривать заявки.</p>
|
||||
</div>
|
||||
}
|
||||
else if (applications is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; height: 2rem; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 90%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (applications.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Новых заявок нет</h2>
|
||||
<p>Когда игроки подадут заявку на участие в клубе, она появится здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="application-list">
|
||||
@foreach (var app in applications)
|
||||
{
|
||||
<li class="glass-card application-item">
|
||||
<div class="application-info">
|
||||
<strong>@app.DisplayName</strong>
|
||||
<span class="status-badge status-neutral">@app.Platform</span>
|
||||
@if (!string.IsNullOrWhiteSpace(app.ExternalUsername))
|
||||
{
|
||||
<span class="application-meta">@app.ExternalUsername</span>
|
||||
}
|
||||
<span class="application-meta">@app.AppliedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||
@if (!string.IsNullOrWhiteSpace(app.Message))
|
||||
{
|
||||
<p class="application-message">«@app.Message»</p>
|
||||
}
|
||||
</div>
|
||||
<div class="application-actions">
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@(busyMembershipId is not null)" @onclick="() => Approve(app.MembershipId)">
|
||||
✅ Одобрить
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(busyMembershipId is not null)" @onclick="() => Reject(app.MembershipId)">
|
||||
❌ Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
|
||||
private List<WebPendingApplication>? applications;
|
||||
private bool accessDenied;
|
||||
private string? errorMessage;
|
||||
private Guid? busyMembershipId;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
accessDenied = false;
|
||||
try
|
||||
{
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Approve(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
busyMembershipId = membershipId;
|
||||
try
|
||||
{
|
||||
await MembershipService.ApproveForCurrentGmAsync(membershipId);
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busyMembershipId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Reject(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
busyMembershipId = membershipId;
|
||||
try
|
||||
{
|
||||
await MembershipService.RejectForCurrentGmAsync(membershipId);
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busyMembershipId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,16 @@
|
||||
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Режим публикации</label>
|
||||
<InputSelect @bind-Value="model.PublicationMode" class="gm-form-control">
|
||||
<option value="@PublicationModeExtensions.NoneValue">Скрыта</option>
|
||||
<option value="@PublicationModeExtensions.CatalogValue">В каталоге</option>
|
||||
<option value="@PublicationModeExtensions.ClubOnlyValue">Только для участников клуба</option>
|
||||
<option value="@PublicationModeExtensions.BothValue">Каталог + клуб</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||
@@ -104,6 +114,7 @@
|
||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||
model.JoinLink = session.JoinLink;
|
||||
model.MaxPlayers = session.MaxPlayers;
|
||||
model.PublicationMode = session.PublicationMode;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
@@ -123,6 +134,7 @@
|
||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||
|
||||
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
|
||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -147,5 +159,6 @@
|
||||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||||
public string JoinLink { get; set; } = "";
|
||||
public int? MaxPlayers { get; set; }
|
||||
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
@page "/group/{GroupId:guid}/completed"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Проведённые сессии — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/group/@GroupId">Группа</a></li>
|
||||
<li class="active">Проведённые сессии</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📚 Проведённые сессии</h2>
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">
|
||||
Добавьте проведённые игры в портфолио — система создаст черновик и предложит заполнить детали.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessions is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 55%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card animate-slide-up">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-title">Проведённых сессий пока нет</div>
|
||||
<p class="empty-state-text">Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-completed-list animate-slide-up">
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
<div class="portfolio-completed-row">
|
||||
<div class="portfolio-completed-info">
|
||||
<a href="/session/@session.Id/history" class="portfolio-completed-title">@session.Title</a>
|
||||
<span class="portfolio-completed-date">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</div>
|
||||
<div class="portfolio-completed-actions">
|
||||
<button type="button" class="btn-gm btn-gm-primary" disabled="@(creatingDraftSessionId == session.Id)" @onclick="() => AddToPortfolio(session.Id)">
|
||||
@(creatingDraftSessionId == session.Id ? "⏳..." : "➕ Добавить в портфолио")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
|
||||
private IReadOnlyList<PortfolioSessionOption>? sessions;
|
||||
private Guid? creatingDraftSessionId;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
sessions = await PortfolioService.GetCompletedSessionsForCurrentUserAsync(GroupId);
|
||||
}
|
||||
|
||||
private async Task AddToPortfolio(Guid sessionId)
|
||||
{
|
||||
errorMessage = null;
|
||||
creatingDraftSessionId = sessionId;
|
||||
|
||||
try
|
||||
{
|
||||
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, sessionId);
|
||||
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось создать черновик: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creatingDraftSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
@page "/group/{GroupId:guid}"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -124,6 +127,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingApplicationsCount > 0)
|
||||
{
|
||||
<a class="glass-card applications-card" href="@($"/group/{GroupId}/applications")">
|
||||
<span class="status-badge status-warning">📨 Заявки участников (@pendingApplicationsCount)</span>
|
||||
<span>Рассмотреть заявки на участие в клубе</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -138,6 +149,60 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (portfolioGames is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Проведённые приключения</h3>
|
||||
<p>Черновики и опубликованные приключения для каталога мастера.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@isCreatingDraft" @onclick="CreateDraft">
|
||||
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Создать")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (portfolioGames.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Приключений пока нет</div>
|
||||
<p class="empty-state-text">Создайте первый черновик и добавьте проведённые сессии.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-management-list">
|
||||
@foreach (var game in portfolioGames)
|
||||
{
|
||||
<div class="portfolio-management-row">
|
||||
<div class="portfolio-management-info">
|
||||
<a href="/portfolio/manage/@game.Id" class="portfolio-management-title">@game.Title</a>
|
||||
<span class="status-badge @(game.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(game.IsPublic ? "Опубликовано" : "Черновик")
|
||||
</span>
|
||||
</div>
|
||||
<div class="portfolio-management-meta">
|
||||
<span class="status-badge status-info">@game.SessionCount игр</span>
|
||||
<span class="status-badge status-info">@game.MasterCount мастеров</span>
|
||||
@if (game.PendingReviewCount > 0)
|
||||
{
|
||||
<span class="status-badge status-warning">@game.PendingReviewCount на модерации</span>
|
||||
}
|
||||
</div>
|
||||
<div class="portfolio-management-actions">
|
||||
<a href="/portfolio/manage/@game.Id" class="btn-gm btn-gm-outline">✏️ Изменить</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<a href="/group/@GroupId/completed" class="btn-gm btn-gm-outline">📜 Все проведённые сессии</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (campaignTemplates is not null)
|
||||
{
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
@@ -257,11 +322,12 @@
|
||||
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||
@FormatBatchPublication(batch)
|
||||
</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||
@(IsBatchPublishBusy(batch)
|
||||
? "Обновляем..."
|
||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||
</button>
|
||||
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
|
||||
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="batch-clone-row">
|
||||
@@ -313,11 +379,12 @@
|
||||
<td>
|
||||
<div class="session-table-actions">
|
||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||
</select>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||
@@ -410,11 +477,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
<select class="gm-form-control" style="flex: 1; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||
</select>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||
@@ -481,8 +549,10 @@
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private WebPublicGroupSettings? publicSettings;
|
||||
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private int pendingApplicationsCount;
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private Guid? processingTemplateId;
|
||||
@@ -490,6 +560,7 @@
|
||||
private Guid? publishingSessionId;
|
||||
private string? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private bool isCreatingDraft;
|
||||
private bool savingPublicSettings;
|
||||
private string? currentPlatform;
|
||||
private string? externalUserId;
|
||||
@@ -545,11 +616,40 @@
|
||||
return;
|
||||
}
|
||||
|
||||
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
||||
|
||||
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
|
||||
|
||||
RebuildBatchModels();
|
||||
RebuildCampaignTemplateModels();
|
||||
RebuildPublicSettingsModel();
|
||||
}
|
||||
|
||||
private async Task CreateDraft()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isCreatingDraft = true;
|
||||
|
||||
try
|
||||
{
|
||||
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, null);
|
||||
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось создать черновик: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCreatingDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SavePublicSettings()
|
||||
{
|
||||
errorMessage = null;
|
||||
@@ -579,7 +679,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
@@ -587,10 +687,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Batch опубликован в публичном расписании."
|
||||
: "Batch скрыт из публичного расписания.";
|
||||
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
|
||||
successMessage = mode switch
|
||||
{
|
||||
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
|
||||
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
|
||||
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
|
||||
_ => "Batch скрыт из публичного расписания."
|
||||
};
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -607,7 +711,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
@@ -615,10 +719,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Сессия опубликована в публичном расписании."
|
||||
: "Сессия скрыта из публичного расписания.";
|
||||
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
|
||||
successMessage = mode switch
|
||||
{
|
||||
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
|
||||
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
|
||||
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
|
||||
_ => "Сессия скрыта из публичного расписания."
|
||||
};
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -988,7 +1096,13 @@
|
||||
IntervalDays = InferIntervalDays(orderedSessions),
|
||||
SessionCount = orderedSessions.Count,
|
||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic),
|
||||
PublicationMode = orderedSessions
|
||||
.Select(s => PublicationModeExtensions.FromDatabaseValue(s.PublicationMode))
|
||||
.GroupBy(m => m)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First()
|
||||
.Key
|
||||
};
|
||||
})
|
||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||
@@ -1135,6 +1249,9 @@
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static PublicationMode ParseMode(object? value) =>
|
||||
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
|
||||
|
||||
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
|
||||
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
|
||||
|
||||
@@ -1187,6 +1304,7 @@
|
||||
public int SessionCount { get; init; }
|
||||
public int PublicSessionCount { get; init; }
|
||||
public bool AllSessionsPublic { get; init; }
|
||||
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
@page "/profile/memberships"
|
||||
@using GmRelay.Web.Services
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Мои клубы — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Мои клубы</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>🏛 Мои клубы</h2>
|
||||
<p>Заявки и активные участия в приватных клубных витринах.</p>
|
||||
</div>
|
||||
|
||||
@if (memberships is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (memberships.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Вы пока не подавали заявок</h2>
|
||||
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
|
||||
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (activeMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up">
|
||||
<h3>Активные участия</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in activeMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
|
||||
@membership.GroupName
|
||||
</a>
|
||||
<span class="status-badge status-success">Участник</span>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||||
Покинуть клуб
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pendingMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||||
<h3>Заявки на рассмотрении</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in pendingMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<span class="membership-name">@membership.GroupName</span>
|
||||
<span class="status-badge status-warning">Ожидает одобрения</span>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||||
Отозвать заявку
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (historyMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||||
<h3>История</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in historyMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<span class="membership-name">@membership.GroupName</span>
|
||||
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
|
||||
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
|
||||
</span>
|
||||
@if (membership.DecidedAt is not null)
|
||||
{
|
||||
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<WebMembership>? memberships;
|
||||
private List<WebMembership> activeMemberships = [];
|
||||
private List<WebMembership> pendingMemberships = [];
|
||||
private List<WebMembership> historyMemberships = [];
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
errorMessage = null;
|
||||
memberships = await MembershipService.GetMineAsync();
|
||||
activeMemberships = memberships.Where(m => m.Status == "Active").ToList();
|
||||
pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList();
|
||||
historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList();
|
||||
}
|
||||
|
||||
private async Task Leave(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
try
|
||||
{
|
||||
await MembershipService.LeaveClubForCurrentUserAsync(membershipId);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
@page "/portfolio/manage/{PortfolioGameId:guid}"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio.Covers
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Портфолио — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/group/@groupId">Группа</a></li>
|
||||
<li class="active">Портфолио</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📚 Управление портфолио</h2>
|
||||
@if (editor is not null)
|
||||
{
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@editor.Title</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||
✅ @successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editor is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Параметры публикации</h3>
|
||||
<p>Управление видимостью и обложкой приключения.</p>
|
||||
</div>
|
||||
<span class="status-badge @(editor.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(editor.IsPublic ? "Опубликовано" : "Черновик")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-grid">
|
||||
<div class="portfolio-editor-cover">
|
||||
@if (!string.IsNullOrEmpty(editor.CoverPath))
|
||||
{
|
||||
<img src="@editor.CoverPath" alt="Обложка" class="portfolio-editor-cover-image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-editor-cover-empty">Обложка не загружена</div>
|
||||
}
|
||||
<InputFile OnChange="HandleFileSelected" accept="image/jpeg,image/png,image/webp" class="portfolio-editor-cover-input" />
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@isUploadingCover" @onclick="TriggerCoverUpload">
|
||||
@(isUploadingCover ? "⏳ Загружаем..." : "🖼 Заменить обложку")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-fields">
|
||||
<EditForm Model="@editorModel" OnValidSubmit="SaveDraft">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название</label>
|
||||
<InputText @bind-Value="editorModel.Title" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес (slug)</label>
|
||||
<InputText @bind-Value="editorModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-run".</div>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Система</label>
|
||||
<InputText @bind-Value="editorModel.System" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Формат</label>
|
||||
<InputText @bind-Value="editorModel.Format" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Описание</label>
|
||||
<InputTextArea @bind-Value="editorModel.Description" class="gm-form-control" />
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSaving">
|
||||
@(isSaving ? "⏳ Сохраняем..." : "💾 Сохранить")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<div class="portfolio-editor-publish-row">
|
||||
<button type="button" class="btn-gm @(editor.IsPublic ? "btn-gm-outline" : "btn-gm-success")" disabled="@isUpdatingPublication" @onclick="() => SetPublication(!editor.IsPublic)">
|
||||
@(isUpdatingPublication
|
||||
? "Обновляем..."
|
||||
: editor.IsPublic ? "Скрыть из каталога" : "Опубликовать")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@isDeleting" @onclick="DeletePortfolio">
|
||||
@(isDeleting ? "⏳ Удаляем..." : "🗑 Удалить")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Проведённые сессии</h3>
|
||||
<p>Отметьте игры, которые вошли в это приключение.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@editorModel.SessionIds.Count</span>
|
||||
</div>
|
||||
<div class="portfolio-option-list">
|
||||
@foreach (var session in editor.Sessions)
|
||||
{
|
||||
<label class="portfolio-option-row">
|
||||
<input type="checkbox" checked="@session.Selected" @onchange="e => ToggleSession(session.Id, (bool)(e.Value ?? false))" />
|
||||
<span class="portfolio-option-title">@session.Title</span>
|
||||
<span class="portfolio-option-meta">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Мастера приключения</h3>
|
||||
<p>Выберите мастеров, которые вели это приключение.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@editorModel.MasterPlayerIds.Count</span>
|
||||
</div>
|
||||
<div class="portfolio-option-list">
|
||||
@foreach (var master in editor.Masters)
|
||||
{
|
||||
<label class="portfolio-option-row">
|
||||
<input type="checkbox" checked="@master.Selected" @onchange="e => ToggleMaster(master.PlayerId, (bool)(e.Value ?? false))" />
|
||||
<span class="portfolio-option-title">@master.DisplayName</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Модерация отзывов</h3>
|
||||
<p>Одобрите, отклоните или скройте отзывы игроков перед публикацией.</p>
|
||||
</div>
|
||||
<span class="status-badge @(editor.Reviews.Any(r => r.ModerationStatus == "Pending") ? "status-warning" : "status-neutral")">
|
||||
@editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (editor.Reviews.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Отзывов пока нет</div>
|
||||
<p class="empty-state-text">Игроки смогут оставить отзыв после публикации приключения.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-review-moderation">
|
||||
@foreach (var review in editor.Reviews)
|
||||
{
|
||||
<div class="portfolio-review-row">
|
||||
<div class="portfolio-review-meta">
|
||||
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||
<span class="status-badge @GetReviewStatusClass(review.ModerationStatus)">@TranslateReviewStatus(review.ModerationStatus)</span>
|
||||
<span class="portfolio-review-date">@review.CreatedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||
</div>
|
||||
<p class="portfolio-review-body">@review.Body</p>
|
||||
<div class="portfolio-review-actions">
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Approved"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Одобрить")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Rejected"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Отклонить")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Hidden"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Скрыть")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid PortfolioGameId { get; set; }
|
||||
|
||||
private PortfolioGameEditor? editor;
|
||||
private PortfolioEditorModel editorModel = new();
|
||||
private Guid? groupId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private bool isSaving;
|
||||
private bool isUploadingCover;
|
||||
private bool isUpdatingPublication;
|
||||
private bool isDeleting;
|
||||
private Guid? moderatingReviewId;
|
||||
private IBrowserFile? pendingCoverFile;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
editor = await PortfolioService.GetPortfolioGameForCurrentUserAsync(PortfolioGameId);
|
||||
if (editor is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
groupId = editor.GroupId;
|
||||
editorModel = new PortfolioEditorModel
|
||||
{
|
||||
Title = editor.Title,
|
||||
PublicSlug = editor.PublicSlug ?? string.Empty,
|
||||
Description = editor.Description ?? string.Empty,
|
||||
System = editor.System ?? string.Empty,
|
||||
Format = editor.Format ?? string.Empty,
|
||||
SessionIds = editor.Sessions.Where(s => s.Selected).Select(s => s.Id).ToList(),
|
||||
MasterPlayerIds = editor.Masters.Where(m => m.Selected).Select(m => m.PlayerId).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private void ToggleSession(Guid sessionId, bool isChecked)
|
||||
{
|
||||
if (isChecked)
|
||||
{
|
||||
if (!editorModel.SessionIds.Contains(sessionId))
|
||||
{
|
||||
editorModel.SessionIds.Add(sessionId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
editorModel.SessionIds.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleMaster(Guid playerId, bool isChecked)
|
||||
{
|
||||
if (isChecked)
|
||||
{
|
||||
if (!editorModel.MasterPlayerIds.Contains(playerId))
|
||||
{
|
||||
editorModel.MasterPlayerIds.Add(playerId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
editorModel.MasterPlayerIds.Remove(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveDraft()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isSaving = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.UpdateDraftForCurrentUserAsync(
|
||||
PortfolioGameId,
|
||||
new PortfolioGameUpdate(
|
||||
editorModel.Title,
|
||||
editorModel.PublicSlug,
|
||||
editorModel.Description,
|
||||
editorModel.System,
|
||||
editorModel.Format,
|
||||
editorModel.SessionIds,
|
||||
editorModel.MasterPlayerIds));
|
||||
successMessage = "Черновик сохранён.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось сохранить: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerCoverUpload()
|
||||
{
|
||||
// The InputFile control is rendered with a label. No-op click handler kept for symmetry.
|
||||
}
|
||||
|
||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
var file = e.File;
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCoverFile = file;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isUploadingCover = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream(LocalPortfolioCoverStorage.MaxBytes);
|
||||
await PortfolioService.ReplaceCoverForCurrentUserAsync(PortfolioGameId, stream, file.ContentType);
|
||||
successMessage = "Обложка обновлена.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось загрузить обложку: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUploadingCover = false;
|
||||
pendingCoverFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetPublication(bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isUpdatingPublication = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.SetPublicationForCurrentUserAsync(PortfolioGameId, isPublic);
|
||||
successMessage = isPublic ? "Приключение опубликовано." : "Приключение скрыто.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публикацию: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUpdatingPublication = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeletePortfolio()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
isDeleting = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.DeleteForCurrentUserAsync(PortfolioGameId);
|
||||
Navigation.NavigateTo(groupId.HasValue ? $"/group/{groupId.Value}" : "/");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось удалить: " + ex.Message;
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Moderate(Guid reviewId, string moderationStatus)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
moderatingReviewId = reviewId;
|
||||
|
||||
try
|
||||
{
|
||||
await PortfolioService.ModerateReviewForCurrentUserAsync(PortfolioGameId, reviewId, moderationStatus);
|
||||
successMessage = "Модерация обновлена.";
|
||||
await Reload();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить отзыв: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
moderatingReviewId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetReviewStatusClass(string status) => status switch
|
||||
{
|
||||
"Approved" => "status-success",
|
||||
"Rejected" => "status-danger",
|
||||
"Hidden" => "status-warning",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateReviewStatus(string status) => status switch
|
||||
{
|
||||
"Approved" => "Одобрен",
|
||||
"Rejected" => "Отклонён",
|
||||
"Hidden" => "Скрыт",
|
||||
_ => "На модерации"
|
||||
};
|
||||
|
||||
private sealed class PortfolioEditorModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string PublicSlug { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string System { get; set; } = string.Empty;
|
||||
public string Format { get; set; } = string.Empty;
|
||||
public List<Guid> SessionIds { get; set; } = new();
|
||||
public List<Guid> MasterPlayerIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,50 @@
|
||||
|
||||
<PageTitle>Профиль — GM-Relay</PageTitle>
|
||||
|
||||
<div class="profile-container">
|
||||
<h1 class="page-title">Профиль</h1>
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Профиль</li>
|
||||
</ul>
|
||||
|
||||
@if (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>
|
||||
<h2 class="section-title">Публичный профиль мастера</h2>
|
||||
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||
<h3>Публичный профиль мастера</h3>
|
||||
<p>Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||
</div>
|
||||
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
|
||||
<span class="status-badge @(masterProfile.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(masterProfile.IsPublic ? "Публичный" : "Скрыт")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
|
||||
@@ -40,7 +72,7 @@
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-gm".</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,20 +104,28 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<p class="loading-text">Загрузка...</p>
|
||||
}
|
||||
else if (identities.Count == 0)
|
||||
{
|
||||
<div class="profile-card">
|
||||
<p>Связанные аккаунты не найдены.</p>
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Связанные аккаунты</h3>
|
||||
<p>Аккаунты Telegram и Discord, привязанные к вашему профилю.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="profile-card">
|
||||
<h2 class="section-title">Связанные аккаунты</h2>
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%;"></div>
|
||||
}
|
||||
else if (identities.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Аккаунты не найдены</div>
|
||||
<p class="empty-state-text">Привяжите Telegram или Discord, чтобы управлять профилем.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="identity-list">
|
||||
@foreach (var id in identities)
|
||||
{
|
||||
@@ -96,7 +136,7 @@
|
||||
</div>
|
||||
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
|
||||
{
|
||||
<button class="btn btn-secondary btn-small"
|
||||
<button class="btn-gm btn-gm-danger"
|
||||
@onclick="() => Unlink(id.Platform, id.ExternalUserId)"
|
||||
disabled="@isUnlinking">
|
||||
Отвязать
|
||||
@@ -104,25 +144,34 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="identity-badge">Текущий</span>
|
||||
<span class="status-badge status-success">Текущий</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Добавить аккаунт</h3>
|
||||
<p>Привяжите дополнительные платформы для входа.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<h2 class="section-title">Добавить аккаунт</h2>
|
||||
@if (!HasLinkedPlatform("Discord"))
|
||||
{
|
||||
<a href="/auth/discord" class="btn btn-primary">
|
||||
<a href="/auth/discord" class="login-btn-discord" style="margin-bottom: 0.75rem;">
|
||||
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
Привязать Discord
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted-text">Discord уже привязан.</p>
|
||||
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">Discord уже привязан.</p>
|
||||
}
|
||||
|
||||
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
|
||||
@@ -138,16 +187,6 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
<div class="alert alert-error">@errorMessage</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(successMessage))
|
||||
{
|
||||
<div class="alert alert-success">@successMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@page "/club/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@using System.Security.Claims
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@@ -58,22 +64,88 @@ else if (club is not null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in club.Sessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
|
||||
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
|
||||
|
||||
@if (publicSessions.Count > 0)
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in publicSessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (membersOnlySessions.Count > 0)
|
||||
{
|
||||
<section class="glass-card members-only-section">
|
||||
<h2>Игры для участников клуба</h2>
|
||||
@if (viewerIsActiveMember)
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in membersOnlySessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge status-warning">Только для участников</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Эти сессии доступны только одобренным участникам клуба.</p>
|
||||
@if (viewerPlayerId is null)
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<details class="application-form">
|
||||
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
|
||||
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
|
||||
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(applicationError))
|
||||
{
|
||||
<p class="form-error">@applicationError</p>
|
||||
}
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
|
||||
</EditForm>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
{
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Завершённые игры клуба</h2>
|
||||
<p>Публичные портфолио, опубликованные мастерами этого клуба.</p>
|
||||
<PortfolioCardGrid Games="portfolioGames" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +153,35 @@ else if (club is not null)
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private WebPublicClub? club;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
private bool loaded;
|
||||
private Guid? viewerPlayerId;
|
||||
private bool viewerIsActiveMember;
|
||||
private string? applicationError;
|
||||
private string? applicationMessage;
|
||||
private bool isSubmittingApplication;
|
||||
|
||||
private async Task TrySubmitApplicationAsync()
|
||||
{
|
||||
applicationError = null;
|
||||
if (club is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isSubmittingApplication = true;
|
||||
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
|
||||
applicationMessage = null;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
applicationError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmittingApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
|
||||
@@ -93,9 +193,42 @@ else if (club is not null)
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
club = string.IsNullOrWhiteSpace(Slug)
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
applicationError = null;
|
||||
applicationMessage = null;
|
||||
|
||||
// Resolve viewer identity (player id) for member-aware access.
|
||||
var user = HttpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
|
||||
{
|
||||
// We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly
|
||||
// by reading both claims. Simpler: only resolve when both Platform and externalUserId are present.
|
||||
var platform = user.FindFirst("Platform")?.Value;
|
||||
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
|
||||
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
|
||||
: null;
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerPlayerId = null;
|
||||
}
|
||||
|
||||
club = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
|
||||
|
||||
if (club is not null && viewerPlayerId is not null)
|
||||
{
|
||||
viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerIsActiveMember = false;
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
@page "/gm/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@@ -83,12 +87,22 @@ else if (profile is not null)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
{
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Портфолио</h2>
|
||||
<p>Завершённые игры мастера, открытые для публичного просмотра.</p>
|
||||
<PortfolioCardGrid Games="portfolioGames" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private GmRelay.Web.Services.PublicMasterProfile? profile;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
|
||||
@@ -101,9 +115,24 @@ else if (profile is not null)
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
profile = string.IsNullOrWhiteSpace(Slug)
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
|
||||
Guid? viewerPlayerId = null;
|
||||
var user = HttpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
|
||||
{
|
||||
var platform = user.FindFirst("Platform")?.Value;
|
||||
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
|
||||
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
|
||||
: null;
|
||||
}
|
||||
|
||||
profile = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
@page "/portfolio/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject AuthorizedPortfolioService AuthorizedPortfolio
|
||||
@inject NavigationManager Navigation
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && game is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Портфолио не найдено</h1>
|
||||
<p>Эта игра скрыта, ещё не опубликована или короткий адрес больше не используется.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (game is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Портфолио {game.Title} — завершённая игра в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(game.CoverPath))
|
||||
{
|
||||
<div class="portfolio-cover-hero" style="background-image: url('@game.CoverPath')"></div>
|
||||
}
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-success">Завершено</span>
|
||||
<h1>@game.Title</h1>
|
||||
<p>Завершено @game.CompletedAt.ToLocalTime().FormatMoscow()</p>
|
||||
<div class="session-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(game.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(game.System)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(game.Format)</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="glass-card public-session-detail">
|
||||
@if (!string.IsNullOrWhiteSpace(game.Description))
|
||||
{
|
||||
<div class="session-description">
|
||||
<h3>Описание</h3>
|
||||
<p>@game.Description</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.Masters.Count > 0)
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Мастера</span>
|
||||
@foreach (var master in game.Masters)
|
||||
{
|
||||
<a href="@($"/gm/{master.Slug}")">@master.DisplayName</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(game.ClubSlug) && !string.IsNullOrWhiteSpace(game.ClubName))
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Клуб</span>
|
||||
<a href="@($"/club/{game.ClubSlug}")">@game.ClubName</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicPortfolioUrl" target="_blank" rel="noopener noreferrer">Ссылка на портфолио</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Отзывы игроков</h2>
|
||||
@if (game.Reviews.Count == 0)
|
||||
{
|
||||
<p>Пока нет одобренных отзывов.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="portfolio-review-list">
|
||||
@foreach (var review in game.Reviews)
|
||||
{
|
||||
<li class="portfolio-review-card">
|
||||
<div class="portfolio-review-meta">
|
||||
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||
<span class="portfolio-review-date">@review.CreatedAt.ToLocalTime().FormatMoscowShort()</span>
|
||||
</div>
|
||||
<p class="portfolio-review-body">@review.Body</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Оставить отзыв</h2>
|
||||
@switch (submissionState)
|
||||
{
|
||||
case PortfolioReviewSubmissionState.RequiresAuthentication:
|
||||
<p>Войдите, чтобы оставить отзыв об этом приключении.</p>
|
||||
<div class="public-settings-actions">
|
||||
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти</a>
|
||||
</div>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.Ineligible:
|
||||
<p>Отзыв могут оставить только игроки, участвовавшие в этом приключении.</p>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.AlreadySubmitted:
|
||||
<p>Отзыв отправлен на модерацию.</p>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.Eligible:
|
||||
<EditForm Model="reviewModel" OnValidSubmit="SubmitReviewAsync" FormName="portfolio-review">
|
||||
<div class="portfolio-editor-fields">
|
||||
<label>
|
||||
<span>Текст отзыва</span>
|
||||
<textarea class="portfolio-review-textarea"
|
||||
@bind="reviewModel.Body"
|
||||
@bind:event="oninput"
|
||||
maxlength="2000"
|
||||
minlength="10"
|
||||
rows="5"
|
||||
placeholder="Что вам запомнилось в этой игре?"
|
||||
required></textarea>
|
||||
</label>
|
||||
<label class="portfolio-review-consent">
|
||||
<input type="checkbox"
|
||||
name="publicationConsent"
|
||||
@bind="reviewModel.PublicationConsent"
|
||||
required />
|
||||
<span>Я даю согласие на публикацию этого отзыва</span>
|
||||
</label>
|
||||
@if (!string.IsNullOrWhiteSpace(submissionError))
|
||||
{
|
||||
<p class="portfolio-review-error">@submissionError</p>
|
||||
}
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "Отправка..." : "Отправить отзыв")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
break;
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private PublicPortfolioGame? game;
|
||||
private PortfolioReviewSubmissionState submissionState = PortfolioReviewSubmissionState.RequiresAuthentication;
|
||||
private ReviewFormModel reviewModel = new();
|
||||
private string? submissionError;
|
||||
private bool isSubmitting;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => game is null ? "Портфолио — GM-Relay" : $"{game.Title} — GM-Relay";
|
||||
|
||||
private string PublicPortfolioUrl => Navigation.ToAbsoluteUri($"/portfolio/{Slug}").ToString();
|
||||
|
||||
private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/portfolio/{Slug}")}";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
game = trimmedSlug is null
|
||||
? null
|
||||
: await PortfolioStore.GetPublicPortfolioGameBySlugAsync(trimmedSlug);
|
||||
|
||||
if (game is not null)
|
||||
{
|
||||
submissionState = await AuthorizedPortfolio.GetReviewSubmissionStateForCurrentUserAsync(game.Slug);
|
||||
}
|
||||
|
||||
reviewModel = new ReviewFormModel();
|
||||
submissionError = null;
|
||||
isSubmitting = false;
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitReviewAsync()
|
||||
{
|
||||
if (game is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reviewModel.PublicationConsent)
|
||||
{
|
||||
submissionError = "Нужно подтвердить согласие на публикацию.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reviewModel.Body) || reviewModel.Body.Trim().Length < 10)
|
||||
{
|
||||
submissionError = "Отзыв должен содержать не меньше 10 символов.";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
submissionError = null;
|
||||
try
|
||||
{
|
||||
await AuthorizedPortfolio.SubmitReviewForCurrentUserAsync(
|
||||
game.Slug,
|
||||
reviewModel.Body,
|
||||
reviewModel.PublicationConsent);
|
||||
submissionState = PortfolioReviewSubmissionState.AlreadySubmitted;
|
||||
reviewModel = new ReviewFormModel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
submissionError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSystemDisplayName(string? system)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(system))
|
||||
return system ?? string.Empty;
|
||||
|
||||
if (Enum.TryParse<GameSystem>(system, out var gs))
|
||||
return gs.ToDisplayName();
|
||||
|
||||
return system;
|
||||
}
|
||||
|
||||
private static string TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
"Offline" => "Офлайн",
|
||||
"Hybrid" => "Гибрид",
|
||||
_ => format
|
||||
};
|
||||
|
||||
private sealed class ReviewFormModel
|
||||
{
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public bool PublicationConsent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
@page "/session/{SessionId:guid}/history"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -22,6 +24,14 @@
|
||||
{
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
|
||||
}
|
||||
@if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow)
|
||||
{
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<button type="button" class="btn-gm btn-gm-primary" disabled="@isCreatingDraft" @onclick="AddToPortfolio">
|
||||
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Добавить в портфолио")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entries is null)
|
||||
@@ -78,6 +88,8 @@
|
||||
private List<SessionAuditLogEntry>? entries;
|
||||
private string? sessionTitle;
|
||||
private Guid? groupId;
|
||||
private WebSession? session;
|
||||
private bool isCreatingDraft;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -88,7 +100,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||
if (session is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
@@ -100,6 +112,30 @@
|
||||
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
|
||||
}
|
||||
|
||||
private async Task AddToPortfolio()
|
||||
{
|
||||
if (groupId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isCreatingDraft = true;
|
||||
|
||||
try
|
||||
{
|
||||
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId);
|
||||
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch
|
||||
{
|
||||
isCreatingDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetChangeTypeLabel(string changeType) => changeType switch
|
||||
{
|
||||
"Title" => "Название",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<div class="portfolio-grid">
|
||||
@foreach (var game in Games)
|
||||
{
|
||||
<a class="portfolio-card" href="@($"/portfolio/{game.Slug}")">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CoverPath))
|
||||
{
|
||||
<div class="portfolio-card-cover" style="background-image: url('@game.CoverPath')"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-card-cover portfolio-card-cover-empty">
|
||||
<span>Без обложки</span>
|
||||
</div>
|
||||
}
|
||||
<div class="portfolio-card-body">
|
||||
<h3>@game.Title</h3>
|
||||
<div class="portfolio-card-meta">
|
||||
<span class="status-badge status-success">Завершено</span>
|
||||
<span class="portfolio-card-date">@game.CompletedAt.ToLocalTime().FormatMoscowShort()</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(game.System) || !string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<div class="portfolio-card-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(game.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(game.System)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(game.Format)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<PublicPortfolioCard> Games { get; set; } = [];
|
||||
|
||||
private static string GetSystemDisplayName(string? system)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(system))
|
||||
return system ?? string.Empty;
|
||||
|
||||
if (Enum.TryParse<GameSystem>(system, out var gs))
|
||||
return gs.ToDisplayName();
|
||||
|
||||
return system;
|
||||
}
|
||||
|
||||
private static string TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
"Offline" => "Офлайн",
|
||||
"Hybrid" => "Гибрид",
|
||||
_ => format
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/dataprotection-keys && chown -R $APP_UID:$APP_UID /app/dataprotection-keys
|
||||
RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \
|
||||
&& chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
USER $APP_UID
|
||||
|
||||
@@ -2,6 +2,8 @@ using GmRelay.Web;
|
||||
using GmRelay.Web.Components;
|
||||
using GmRelay.Web.Health;
|
||||
using GmRelay.Web.Services;
|
||||
using GmRelay.Web.Services.Portfolio;
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@@ -37,12 +39,16 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
||||
|
||||
// Add Services
|
||||
builder.Services.AddSingleton<TelegramAuthService>();
|
||||
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
|
||||
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
||||
builder.Services.AddSingleton<DiscordAuthService>();
|
||||
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||
builder.Services.AddScoped<AuthorizedMembershipService>();
|
||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
||||
builder.Services.AddScoped<AuthorizedPortfolioService>();
|
||||
|
||||
// Add Bot Client
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
@@ -94,6 +100,8 @@ app.Use(async (context, next) =>
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UsePortfolioCoverFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Security.Claims;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed class AuthorizedMembershipService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||
return null;
|
||||
|
||||
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
|
||||
return (platform, externalUserId, name);
|
||||
}
|
||||
|
||||
public async Task<Guid> ApplyForCurrentUserAsync(Guid groupId, string? message)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Player record not found for current user.");
|
||||
}
|
||||
|
||||
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
|
||||
if (normalizedMessage?.Length > 1000)
|
||||
{
|
||||
throw new InvalidOperationException("Сообщение заявки должно быть не длиннее 1000 символов.");
|
||||
}
|
||||
|
||||
return await sessionStore.ApplyForMembershipAsync(groupId, playerId.Value, normalizedMessage);
|
||||
}
|
||||
|
||||
public async Task<List<WebMembership>> GetMineAsync()
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return [];
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
return [];
|
||||
|
||||
return await sessionStore.GetMembershipsForPlayerAsync(playerId.Value);
|
||||
}
|
||||
|
||||
public async Task LeaveClubForCurrentUserAsync(Guid membershipId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
throw new InvalidOperationException("Player record not found for current user.");
|
||||
|
||||
await sessionStore.LeaveClubMembershipAsync(membershipId, playerId.Value);
|
||||
}
|
||||
|
||||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return await sessionStore.GetPendingApplicationsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingApplicationsCountForCurrentGmAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return 0;
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
return 0;
|
||||
|
||||
return await sessionStore.GetPendingApplicationsCountAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task ApproveForCurrentGmAsync(Guid membershipId)
|
||||
{
|
||||
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
|
||||
await sessionStore.ApproveMembershipAsync(membershipId, approverPlayerId);
|
||||
}
|
||||
|
||||
public async Task RejectForCurrentGmAsync(Guid membershipId)
|
||||
{
|
||||
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
|
||||
await sessionStore.RejectMembershipAsync(membershipId, approverPlayerId);
|
||||
}
|
||||
|
||||
private async Task<(Guid ApproverPlayerId, Guid GroupId)> ResolveMembershipContextForGmAsync(Guid membershipId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
throw new InvalidOperationException("Player record not found for current user.");
|
||||
|
||||
var groupId = await sessionStore.GetGroupIdForMembershipAsync(membershipId);
|
||||
if (groupId is null)
|
||||
throw new InvalidOperationException($"Membership {membershipId} not found.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId.Value, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (playerId.Value, groupId.Value);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
normalizedBio);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
await sessionStore.SetSessionPublicationModeAsync(sessionId, session.GroupId, mode);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeForCurrentUserAsync(Guid batchId, PublicationMode mode)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
await sessionStore.SetBatchPublicationModeAsync(batchId, batch.GroupId, mode);
|
||||
}
|
||||
|
||||
public async Task<bool> IsActiveClubMemberForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return false;
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
return false;
|
||||
|
||||
return await sessionStore.IsActiveClubMemberAsync(groupId, playerId.Value);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
|
||||
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue,
|
||||
bool IsMembersOnly = false,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
public sealed record WebMembership(
|
||||
Guid MembershipId,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Status,
|
||||
string Role,
|
||||
string? Message,
|
||||
DateTime AppliedAt,
|
||||
DateTime? DecidedAt,
|
||||
string? DecidedByDisplayName);
|
||||
|
||||
public sealed record WebPendingApplication(
|
||||
Guid MembershipId,
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
string Platform,
|
||||
string? ExternalUsername,
|
||||
string? Message,
|
||||
DateTime AppliedAt);
|
||||
|
||||
public sealed record WebClubShowcaseSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode,
|
||||
bool IsMembersOnly,
|
||||
string? Description,
|
||||
bool AllowDirectRegistration);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
@@ -79,12 +120,14 @@ public interface ISessionStore
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
|
||||
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
|
||||
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
@@ -110,7 +153,7 @@ public interface ISessionStore
|
||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
|
||||
// --- Identity linking (issue #35) ---
|
||||
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
||||
@@ -123,6 +166,17 @@ public interface ISessionStore
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
|
||||
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
|
||||
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
|
||||
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
|
||||
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
|
||||
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
|
||||
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
|
||||
}
|
||||
|
||||
public sealed record LinkedIdentity(
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Security.Claims;
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public sealed class AuthorizedPortfolioService(
|
||||
IPortfolioStore portfolioStore,
|
||||
ISessionStore sessionStore,
|
||||
IPortfolioCoverStorage coverStorage,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private (string Platform, string ExternalUserId, string? Name)? GetCurrentIdentity()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||
return null;
|
||||
|
||||
var name = user.FindFirst(ClaimTypes.Name)?.Value;
|
||||
return (platform, externalUserId, name);
|
||||
}
|
||||
|
||||
private async Task<(string Platform, string ExternalUserId)> RequireManagerAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, "<anonymous>");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
private async Task<(Guid GroupId, string Platform, string ExternalUserId)> RequireManagerForGameAsync(Guid portfolioGameId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(portfolioGameId, "<anonymous>");
|
||||
}
|
||||
|
||||
var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId);
|
||||
if (groupId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Portfolio game not found.");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(portfolioGameId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
// --- Protected reads ---
|
||||
|
||||
public async Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await portfolioStore.GetPortfolioGamesForGroupAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<PortfolioGameEditor?> GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId);
|
||||
if (groupId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await portfolioStore.GetPortfolioGameForManagementAsync(portfolioGameId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortfolioSessionOption>> GetCompletedSessionsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await portfolioStore.GetEligibleCompletedSessionsAsync(groupId, null);
|
||||
}
|
||||
|
||||
// --- Protected writes ---
|
||||
|
||||
public async Task<Guid> CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId)
|
||||
{
|
||||
await RequireManagerAsync(groupId);
|
||||
return await portfolioStore.CreatePortfolioDraftAsync(groupId, preselectedSessionId);
|
||||
}
|
||||
|
||||
public async Task UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var normalized = NormalizeUpdate(update);
|
||||
await portfolioStore.UpdatePortfolioDraftAsync(portfolioGameId, groupId, normalized);
|
||||
}
|
||||
|
||||
public async Task ReplaceCoverForCurrentUserAsync(
|
||||
Guid portfolioGameId,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var saveResult = await coverStorage.SaveAsync(content, contentType, cancellationToken);
|
||||
var newKey = saveResult.StorageKey;
|
||||
|
||||
try
|
||||
{
|
||||
var oldKey = await portfolioStore.SetPortfolioCoverAsync(portfolioGameId, groupId, newKey);
|
||||
if (!string.IsNullOrWhiteSpace(oldKey))
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(oldKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(newKey, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteForCurrentUserAsync(Guid portfolioGameId)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var coverKey = await portfolioStore.DeletePortfolioGameAsync(portfolioGameId, groupId);
|
||||
if (!string.IsNullOrWhiteSpace(coverKey))
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(coverKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
await portfolioStore.SetPortfolioPublicationAsync(portfolioGameId, groupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task ModerateReviewForCurrentUserAsync(
|
||||
Guid portfolioGameId,
|
||||
Guid reviewId,
|
||||
string moderationStatus)
|
||||
{
|
||||
var (groupId, platform, externalUserId) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var moderatorPlayerId = await sessionStore.ResolveEffectivePlayerIdAsync(platform, externalUserId);
|
||||
if (moderatorPlayerId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authenticated player not found.");
|
||||
}
|
||||
|
||||
await portfolioStore.ModeratePortfolioReviewAsync(
|
||||
reviewId,
|
||||
portfolioGameId,
|
||||
groupId,
|
||||
moderatorPlayerId.Value,
|
||||
moderationStatus);
|
||||
}
|
||||
|
||||
// --- Review submission ---
|
||||
|
||||
public async Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateForCurrentUserAsync(string slug)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return PortfolioReviewSubmissionState.RequiresAuthentication;
|
||||
}
|
||||
|
||||
return await portfolioStore.GetReviewSubmissionStateAsync(slug, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
public async Task SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent)
|
||||
{
|
||||
if (!publicationConsent)
|
||||
{
|
||||
throw new InvalidOperationException("Public review requires explicit consent.");
|
||||
}
|
||||
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(Guid.Empty, "<anonymous>");
|
||||
}
|
||||
|
||||
var normalizedSlug = PortfolioValidation.NormalizeSlug(slug);
|
||||
var normalizedBody = PortfolioValidation.NormalizeReviewBody(body);
|
||||
|
||||
var displayName = identity.Value.Name?.Trim() ?? identity.Value.ExternalUserId;
|
||||
if (displayName.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Display name is required.");
|
||||
}
|
||||
|
||||
await portfolioStore.SubmitPortfolioReviewAsync(
|
||||
normalizedSlug,
|
||||
identity.Value.Platform,
|
||||
identity.Value.ExternalUserId,
|
||||
displayName,
|
||||
normalizedBody);
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private static PortfolioGameUpdate NormalizeUpdate(PortfolioGameUpdate update)
|
||||
{
|
||||
var title = PortfolioValidation.NormalizeTitle(update.Title);
|
||||
var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug);
|
||||
var description = PortfolioValidation.NormalizeDescription(update.Description);
|
||||
var format = PortfolioValidation.NormalizeFormat(update.Format);
|
||||
var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim();
|
||||
|
||||
return update with
|
||||
{
|
||||
Title = title,
|
||||
PublicSlug = slug,
|
||||
Description = description,
|
||||
System = system,
|
||||
Format = format
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType);
|
||||
|
||||
public interface IPortfolioCoverStorage
|
||||
{
|
||||
Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default);
|
||||
|
||||
string GetPublicPath(string storageKey);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed class LocalPortfolioCoverStorage : IPortfolioCoverStorage
|
||||
{
|
||||
public const long MaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Regex SafeKeyPattern = new(
|
||||
"^[a-f0-9]{32}\\.(jpg|png|webp)$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];
|
||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
private static readonly byte[] RiffMarker = "RIFF"u8.ToArray();
|
||||
private static readonly byte[] WebpMarker = "WEBP"u8.ToArray();
|
||||
|
||||
private readonly string _storagePath;
|
||||
private readonly ILogger<LocalPortfolioCoverStorage> _logger;
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options)
|
||||
: this(options, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger<LocalPortfolioCoverStorage>? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.StoragePath))
|
||||
{
|
||||
throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured.");
|
||||
}
|
||||
|
||||
_storagePath = options.StoragePath;
|
||||
_logger = logger ?? NullLogger<LocalPortfolioCoverStorage>.Instance;
|
||||
}
|
||||
|
||||
public async Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
throw new InvalidOperationException("Content type must be provided.");
|
||||
}
|
||||
|
||||
var extension = NormalizeExtension(contentType);
|
||||
|
||||
// Buffer the stream so we can reject oversize uploads before writing to disk
|
||||
// and so we have the bytes we need for signature validation.
|
||||
await using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken);
|
||||
if (buffer.Length > MaxBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cover image exceeds the {MaxBytes}-byte size limit.");
|
||||
}
|
||||
|
||||
var signature = buffer.GetBuffer();
|
||||
var signatureLength = (int)buffer.Length;
|
||||
ValidateSignature(extension, signature, signatureLength);
|
||||
|
||||
Directory.CreateDirectory(_storagePath);
|
||||
var finalName = Guid.NewGuid().ToString("N") + extension;
|
||||
var finalPath = Path.Combine(_storagePath, finalName);
|
||||
var tempPath = finalPath + ".tmp";
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None))
|
||||
{
|
||||
buffer.Position = 0;
|
||||
await buffer.CopyToAsync(tempStream, cancellationToken);
|
||||
await tempStream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(tempPath, finalPath, overwrite: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new PortfolioCoverUploadResult(finalName, ResolveContentType(extension));
|
||||
}
|
||||
|
||||
public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
EnsureSafeKey(storageKey);
|
||||
|
||||
var path = Path.Combine(_storagePath, storageKey);
|
||||
TryDelete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string GetPublicPath(string storageKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
return "/portfolio-covers/" + Uri.EscapeDataString(storageKey);
|
||||
}
|
||||
|
||||
private static void ValidateSignature(string extension, byte[] data, int length)
|
||||
{
|
||||
var isValid = extension switch
|
||||
{
|
||||
".jpg" => StartsWith(data, length, JpegSignature),
|
||||
".png" => StartsWith(data, length, PngSignature),
|
||||
".webp" => StartsWith(data, length, RiffMarker)
|
||||
&& ContainsAt(data, RiffMarker.Length + 4, WebpMarker),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cover signature does not match the declared content type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StartsWith(byte[] data, int length, byte[] prefix)
|
||||
{
|
||||
if (length < prefix.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
{
|
||||
if (data[i] != prefix[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContainsAt(byte[] data, int offset, byte[] needle)
|
||||
{
|
||||
if (offset + needle.Length > data.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < needle.Length; i++)
|
||||
{
|
||||
if (data[offset + i] != needle[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string contentType)
|
||||
{
|
||||
var normalized = contentType.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"image/jpeg" or "image/jpg" => ".jpg",
|
||||
"image/png" => ".png",
|
||||
"image/webp" => ".webp",
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported cover content type: '{contentType}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
private static void EnsureSafeKey(string storageKey)
|
||||
{
|
||||
if (!SafeKeyPattern.IsMatch(storageKey))
|
||||
{
|
||||
throw new InvalidOperationException("Cover storage key is not in the expected format.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cover file '{Path}'.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public static class PortfolioCoverStorageExtensions
|
||||
{
|
||||
public static IServiceCollection AddPortfolioCoverStorage(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services
|
||||
.AddOptions<PortfolioCoverStorageOptions>()
|
||||
.Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName))
|
||||
.Validate(
|
||||
o => !string.IsNullOrWhiteSpace(o.StoragePath),
|
||||
"PortfolioCovers:StoragePath must be configured.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IPortfolioCoverStorage>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<
|
||||
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
|
||||
var logger = sp.GetService<ILoggerFactory>()?.CreateLogger<LocalPortfolioCoverStorage>()
|
||||
?? NullLogger<LocalPortfolioCoverStorage>.Instance;
|
||||
return new LocalPortfolioCoverStorage(options, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static WebApplication UsePortfolioCoverFiles(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
var options = app.Services.GetRequiredService<
|
||||
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
|
||||
|
||||
var storagePath = Path.IsPathRooted(options.StoragePath)
|
||||
? options.StoragePath
|
||||
: Path.Combine(app.Environment.ContentRootPath, options.StoragePath);
|
||||
|
||||
Directory.CreateDirectory(storagePath);
|
||||
|
||||
var contentTypeProvider = new FileExtensionContentTypeProvider();
|
||||
if (!contentTypeProvider.Mappings.ContainsKey(".jpg"))
|
||||
{
|
||||
contentTypeProvider.Mappings[".jpg"] = "image/jpeg";
|
||||
}
|
||||
|
||||
if (!contentTypeProvider.Mappings.ContainsKey(".png"))
|
||||
{
|
||||
contentTypeProvider.Mappings[".png"] = "image/png";
|
||||
}
|
||||
|
||||
if (!contentTypeProvider.Mappings.ContainsKey(".webp"))
|
||||
{
|
||||
contentTypeProvider.Mappings[".webp"] = "image/webp";
|
||||
}
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(storagePath),
|
||||
RequestPath = "/portfolio-covers",
|
||||
ContentTypeProvider = contentTypeProvider,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed class PortfolioCoverStorageOptions
|
||||
{
|
||||
public const string SectionName = "PortfolioCovers";
|
||||
|
||||
public string StoragePath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public interface IPortfolioStore
|
||||
{
|
||||
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
|
||||
|
||||
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
|
||||
|
||||
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
|
||||
|
||||
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
|
||||
|
||||
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
|
||||
|
||||
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
|
||||
|
||||
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||
|
||||
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||
|
||||
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
|
||||
|
||||
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
|
||||
|
||||
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
|
||||
|
||||
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
|
||||
|
||||
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
|
||||
|
||||
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
|
||||
|
||||
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
|
||||
|
||||
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public sealed record PublicPortfolioCard(
|
||||
string Slug,
|
||||
string Title,
|
||||
string CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt);
|
||||
|
||||
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
|
||||
|
||||
public sealed record PublicPortfolioReview(
|
||||
string AuthorDisplayName,
|
||||
string Body,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record PublicPortfolioGame(
|
||||
string Slug,
|
||||
string Title,
|
||||
string Description,
|
||||
string CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt,
|
||||
string? ClubName,
|
||||
string? ClubSlug,
|
||||
IReadOnlyList<PublicPortfolioMaster> Masters,
|
||||
IReadOnlyList<PublicPortfolioReview> Reviews);
|
||||
|
||||
public sealed record PortfolioGameSummary(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
bool IsPublic,
|
||||
DateTime CompletedAt,
|
||||
int SessionCount,
|
||||
int MasterCount,
|
||||
int PendingReviewCount);
|
||||
|
||||
public sealed record PortfolioSessionOption(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
bool Selected);
|
||||
|
||||
public sealed record PortfolioMasterOption(
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
bool Selected);
|
||||
|
||||
public sealed record PortfolioReviewForModeration(
|
||||
Guid Id,
|
||||
string AuthorDisplayName,
|
||||
string Body,
|
||||
string ModerationStatus,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record PortfolioGameEditor(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
string? Description,
|
||||
string? CoverPath,
|
||||
string? System,
|
||||
string? Format,
|
||||
DateTime CompletedAt,
|
||||
bool IsPublic,
|
||||
IReadOnlyList<PortfolioSessionOption> Sessions,
|
||||
IReadOnlyList<PortfolioMasterOption> Masters,
|
||||
IReadOnlyList<PortfolioReviewForModeration> Reviews);
|
||||
|
||||
public sealed record PortfolioGameUpdate(
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
string? Description,
|
||||
string? System,
|
||||
string? Format,
|
||||
IReadOnlyList<Guid> SessionIds,
|
||||
IReadOnlyList<Guid> MasterPlayerIds);
|
||||
|
||||
public enum PortfolioReviewSubmissionState
|
||||
{
|
||||
RequiresAuthentication,
|
||||
Ineligible,
|
||||
Eligible,
|
||||
AlreadySubmitted
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public static class PortfolioValidation
|
||||
{
|
||||
private const int MinSlugLength = 3;
|
||||
private const int MaxSlugLength = 160;
|
||||
private const int MinTitleLength = 2;
|
||||
private const int MaxTitleLength = 255;
|
||||
private const int MaxDescriptionLength = 5000;
|
||||
private const int MinReviewBodyLength = 10;
|
||||
private const int MaxReviewBodyLength = 2000;
|
||||
|
||||
private static readonly HashSet<string> AllowedFormats = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Online",
|
||||
"Offline",
|
||||
"Hybrid"
|
||||
};
|
||||
|
||||
public static string NormalizeSlug(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Slug must not be empty.");
|
||||
}
|
||||
|
||||
var trimmed = value.Trim().ToLowerInvariant();
|
||||
|
||||
var builder = new StringBuilder(trimmed.Length);
|
||||
var previousWasHyphen = false;
|
||||
foreach (var raw in trimmed)
|
||||
{
|
||||
char c;
|
||||
if (raw == ' ' || raw == '_' || raw == '-')
|
||||
{
|
||||
c = '-';
|
||||
}
|
||||
else if (IsAsciiAlphanumeric(raw))
|
||||
{
|
||||
c = raw;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Slug contains unsupported character: '{raw}'.");
|
||||
}
|
||||
|
||||
if (c == '-')
|
||||
{
|
||||
if (builder.Length == 0 || previousWasHyphen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('-');
|
||||
previousWasHyphen = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(c);
|
||||
previousWasHyphen = false;
|
||||
}
|
||||
}
|
||||
|
||||
while (builder.Length > 0 && builder[^1] == '-')
|
||||
{
|
||||
builder.Length--;
|
||||
}
|
||||
|
||||
if (builder.Length < MinSlugLength || builder.Length > MaxSlugLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Slug length must be between {MinSlugLength} and {MaxSlugLength} characters.");
|
||||
}
|
||||
|
||||
// The normalization loop guarantees the output matches ^[a-z0-9]+(?:-[a-z0-9]+)*$,
|
||||
// so no post-loop regex check is required.
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static string NormalizeTitle(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Title must not be empty.");
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < MinTitleLength || trimmed.Length > MaxTitleLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Title length must be between {MinTitleLength} and {MaxTitleLength} characters.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static string? NormalizeDescription(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length > MaxDescriptionLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Description must be at most {MaxDescriptionLength} characters.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static string NormalizeReviewBody(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Review body must not be empty.");
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length < MinReviewBodyLength || trimmed.Length > MaxReviewBodyLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Review body length must be between {MinReviewBodyLength} and {MaxReviewBodyLength} characters.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static string? NormalizeFormat(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (!AllowedFormats.Contains(trimmed))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Format must be one of: {string.Join(", ", AllowedFormats)}.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static bool IsAsciiAlphanumeric(char c) =>
|
||||
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
||||
}
|
||||
@@ -69,7 +69,19 @@ public sealed record WebSession(
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||
int? ThreadId = null,
|
||||
bool IsPublic = false);
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue)
|
||||
{
|
||||
public bool IsPublic
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
|
||||
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
|
||||
}
|
||||
|
||||
public sealed record WebParticipant(
|
||||
Guid Id,
|
||||
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string? MasterProfileSlug,
|
||||
string? MasterDisplayName);
|
||||
string? MasterDisplayName,
|
||||
string PublicationMode = "None",
|
||||
bool IsMembersOnly = false);
|
||||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||||
|
||||
public sealed class SessionService(
|
||||
@@ -233,7 +247,7 @@ public sealed class SessionService(
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
@@ -266,18 +280,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -285,18 +299,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -304,7 +318,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
@@ -327,7 +341,7 @@ public sealed class SessionService(
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
ORDER BY gm.created_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
@@ -345,11 +359,11 @@ public sealed class SessionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
@@ -364,6 +378,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -394,7 +410,7 @@ public sealed class SessionService(
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
ORDER BY gm.created_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
@@ -404,9 +420,21 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
""",
|
||||
new
|
||||
{
|
||||
@@ -414,7 +442,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -441,7 +470,9 @@ public sealed class SessionService(
|
||||
s.allow_direct_registration AS AllowDirectRegistration,
|
||||
s.description AS Description,
|
||||
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
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -470,7 +501,7 @@ public sealed class SessionService(
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
ORDER BY gm.created_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
@@ -479,7 +510,7 @@ public sealed class SessionService(
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
@@ -518,7 +549,10 @@ public sealed class SessionService(
|
||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
r.Description,
|
||||
PublicationMode: r.PublicationMode,
|
||||
IsMembersOnly: r.IsMembersOnly,
|
||||
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||
@@ -544,7 +578,9 @@ public sealed class SessionService(
|
||||
s.allow_direct_registration AS AllowDirectRegistration,
|
||||
s.description AS Description,
|
||||
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
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -573,7 +609,7 @@ public sealed class SessionService(
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
ORDER BY gm.created_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
@@ -583,7 +619,7 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
@@ -603,7 +639,10 @@ public sealed class SessionService(
|
||||
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
||||
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
|
||||
row.Description,
|
||||
PublicationMode: row.PublicationMode,
|
||||
IsMembersOnly: row.IsMembersOnly,
|
||||
row.MasterProfileSlug, row.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||
@@ -617,7 +656,7 @@ public sealed class SessionService(
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
@@ -868,7 +907,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -907,7 +946,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -967,7 +1006,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -1054,7 +1093,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1181,7 +1220,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1951,7 +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();
|
||||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||||
@@ -1971,7 +2010,7 @@ public sealed class SessionService(
|
||||
return null;
|
||||
|
||||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
|
||||
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||||
}
|
||||
|
||||
@@ -2004,7 +2043,8 @@ public sealed class SessionService(
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid playerId)
|
||||
Guid playerId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2018,6 +2058,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2051,9 +2093,21 @@ public sealed class SessionService(
|
||||
) waitlist_counts ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2061,13 +2115,15 @@ public sealed class SessionService(
|
||||
PlayerId = playerId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
Guid groupId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2081,6 +2137,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2111,7 +2169,7 @@ public sealed class SessionService(
|
||||
FROM group_managers gm
|
||||
WHERE gm.group_id = g.id
|
||||
AND gm.role = @OwnerRole
|
||||
ORDER BY gm.added_at
|
||||
ORDER BY gm.created_at
|
||||
LIMIT 1
|
||||
) owner_manager ON true
|
||||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||||
@@ -2121,9 +2179,21 @@ public sealed class SessionService(
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2132,7 +2202,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
@@ -2432,4 +2503,248 @@ public sealed class SessionService(
|
||||
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
|
||||
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var count = await conn.ExecuteScalarAsync<long>(
|
||||
"""
|
||||
SELECT COUNT(*) FROM club_memberships
|
||||
WHERE group_id = @GroupId
|
||||
AND player_id = @PlayerId
|
||||
AND status = 'Active'
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
|
||||
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebClubShowcaseSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.system AS System,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.format AS Format,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.cover_image_url AS CoverImageUrl,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
s.description AS Description,
|
||||
s.allow_direct_registration AS AllowDirectRegistration
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at ASC
|
||||
OFFSET @Offset LIMIT @PageSize
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId,
|
||||
Offset = page * pageSize,
|
||||
PageSize = pageSize
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND status = 'Pending'
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebPendingApplication>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.platform AS Platform,
|
||||
p.external_username AS ExternalUsername,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt
|
||||
FROM club_memberships cm
|
||||
JOIN players p ON p.id = cm.player_id
|
||||
WHERE cm.group_id = @GroupId
|
||||
AND cm.status = 'Pending'
|
||||
ORDER BY cm.applied_at ASC
|
||||
""",
|
||||
new { GroupId = groupId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebMembership>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
cm.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
cm.status AS Status,
|
||||
cm.role AS Role,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt,
|
||||
cm.decided_at AS DecidedAt,
|
||||
decider.display_name AS DecidedByDisplayName
|
||||
FROM club_memberships cm
|
||||
JOIN game_groups g ON g.id = cm.group_id
|
||||
LEFT JOIN players decider ON decider.id = cm.decided_by
|
||||
WHERE cm.player_id = @PlayerId
|
||||
ORDER BY cm.applied_at DESC
|
||||
""",
|
||||
new { PlayerId = playerId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var existing = await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
if (existing > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Active or pending application already exists for this player.");
|
||||
}
|
||||
|
||||
return await conn.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO club_memberships (group_id, player_id, status, message)
|
||||
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
|
||||
RETURNING id
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId, Message = message });
|
||||
}
|
||||
|
||||
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
// Active membership: withdraw by setting status = 'Left'.
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Left', decided_at = now()
|
||||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
|
||||
""",
|
||||
new { MembershipId = membershipId, PlayerId = playerId });
|
||||
if (rows > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
|
||||
var cancelled = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Rejected', decided_at = now()
|
||||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, PlayerId = playerId });
|
||||
if (cancelled == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT group_id FROM club_memberships WHERE id = @MembershipId
|
||||
""",
|
||||
new { MembershipId = membershipId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"PortfolioCovers": {
|
||||
"StoragePath": "../../artifacts/portfolio-covers"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1449,6 +1449,62 @@ body.telegram-mini-app .session-card-mobile {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
/* === Identity list (profile page) === */
|
||||
.identity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 1rem;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.identity-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.identity-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.identity-platform {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
}
|
||||
|
||||
.telegram-widget-wrapper {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* === Sidebar refinements (MainLayout & NavMenu) === */
|
||||
.page {
|
||||
display: flex;
|
||||
@@ -1965,3 +2021,427 @@ body.telegram-mini-app .session-card-mobile {
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* === Portfolio Management === */
|
||||
.portfolio-management-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-management-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.6fr) auto;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portfolio-management-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portfolio-management-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.portfolio-management-title:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.portfolio-management-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portfolio-management-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.portfolio-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr);
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-editor-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.portfolio-editor-cover-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.portfolio-editor-cover-empty {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portfolio-editor-cover-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.portfolio-editor-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portfolio-editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-editor-publish-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.portfolio-option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-option-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.portfolio-option-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.portfolio-option-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.portfolio-review-moderation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-review-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portfolio-review-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-review-author {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portfolio-review-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.portfolio-review-body {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.portfolio-review-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-completed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-completed-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portfolio-completed-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portfolio-completed-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.portfolio-completed-title:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.portfolio-completed-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.portfolio-completed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.portfolio-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portfolio-management-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portfolio-management-actions,
|
||||
.portfolio-completed-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.portfolio-option-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.portfolio-option-meta {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portfolio-completed-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Public Portfolio === */
|
||||
.portfolio-section {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portfolio-section h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.portfolio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.portfolio-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.portfolio-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.portfolio-card-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: var(--bg-secondary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.portfolio-card-cover-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.portfolio-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.portfolio-card-body h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.portfolio-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portfolio-card-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portfolio-card-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portfolio-cover-hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 7;
|
||||
background-color: var(--bg-secondary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.portfolio-review-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portfolio-review-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portfolio-review-textarea {
|
||||
width: 100%;
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.portfolio-review-textarea:focus {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.portfolio-review-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.portfolio-review-error {
|
||||
margin: 0;
|
||||
color: var(--status-error, #ff6b6b);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.portfolio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.portfolio-cover-hero {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(
|
||||
future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// 15:00 MSK = 12:00 UTC
|
||||
Assert.Equal(12, result.Value.Hour);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
/// <summary>
|
||||
/// Source-level structural smoke tests for the Discord wizard
|
||||
/// interaction module. The NetCord component-interaction service
|
||||
/// uses dispatch-by-attribute (no public registry), so a runtime
|
||||
/// instantiation test would need to spin up the full NetCord host —
|
||||
/// overkill for a smoke gate. Instead we assert on the source shape
|
||||
/// (custom-id formats, handler method signatures, dispatcher wiring)
|
||||
/// so the existing wizard tests catch regressions in the platform-
|
||||
/// neutral state machine while this file catches regressions in the
|
||||
/// Discord adapter shell.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardInteractionModuleSourceTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
private static string ReadSource(string relativePath)
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var fullPath = Path.Combine(repoRoot, relativePath);
|
||||
Assert.True(File.Exists(fullPath), $"Source file {relativePath} should exist.");
|
||||
return File.ReadAllText(fullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Module_ShouldExist()
|
||||
{
|
||||
var path = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs";
|
||||
var source = ReadSource(path);
|
||||
Assert.Contains("public sealed class DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public sealed class DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public sealed class DiscordWizardModalModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public sealed class WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_ShouldBeDerivedFromComponentInteractionModule()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The dispatching modules are thin shells that inherit from
|
||||
// NetCord's ComponentInteractionModule<TContext> for each
|
||||
// supported component type.
|
||||
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ComponentInteractionModule<StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("ComponentInteractionModule<ModalInteractionContext>", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_ShouldRegisterWizardComponentInteraction()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// All three modules use the [ComponentInteraction("wizard")]
|
||||
// prefix registration; the args string carries the rest of
|
||||
// the custom-id (e.g. "btn:choice:Type:single" or
|
||||
// "select:Visibility" or "modal:Title").
|
||||
var count = CountOccurrences(source, "[ComponentInteraction(\"wizard\")]");
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldHandleButtonSelectAndModal()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
Assert.Contains("public async Task HandleButtonAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public async Task HandleStringMenuAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("public async Task HandleModalAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldParseAllWizardActionKinds()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The button handler must dispatch on the five action kinds
|
||||
// the wizard's callback data format emits: choice, back,
|
||||
// cancel, create. The resume flow is a wizard-internal control
|
||||
// emitted by the slash command's "Continue / Start over" row.
|
||||
Assert.Contains("\"choice\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"back\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"cancel\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"create\"", source, StringComparison.Ordinal);
|
||||
Assert.Contains("\"resume\"", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldWireWizardStateMachine()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The dispatcher must call the shared GameCreationWizard's
|
||||
// HandleInteractionAsync, which is the same entry point the
|
||||
// Telegram bot uses. This is the core invariant of the
|
||||
// platform-neutral refactor.
|
||||
Assert.Contains("GameCreationWizard", source, StringComparison.Ordinal);
|
||||
Assert.Contains("HandleInteractionAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldInvokeSubmitterOnCreate()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The "create" callback must delegate to DiscordWizardSubmitter
|
||||
// (the 3-retry finalize loop) rather than call the wizard's
|
||||
// render path.
|
||||
Assert.Contains("SubmitAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterAllThreeComponentServices()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
||||
// NetCord requires AddComponentInteractions<TInteraction, TContext>
|
||||
// per supported interaction type. The wizard needs all three:
|
||||
// buttons, StringSelectMenus, and modal submits.
|
||||
Assert.Contains("AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("AddComponentInteractions<StringMenuInteraction, StringMenuInteractionContext>", source, StringComparison.Ordinal);
|
||||
Assert.Contains("AddComponentInteractions<ModalInteraction, ModalInteractionContext>", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterWizardModuleClasses()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Program.cs");
|
||||
// The wizard module classes have constructor dependencies
|
||||
// (the dispatcher + shared services) that DI must resolve.
|
||||
// AddComponentInteractions only registers the IComponentInteractionService,
|
||||
// not the module classes themselves.
|
||||
Assert.Contains("WizardInteractionDispatcher", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordWizardButtonModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordWizardStringMenuModule", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordWizardModalModule", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_ShouldLookupDraftByOwner()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// All three handlers must look up the active draft by
|
||||
// (platform="Discord", ownerId=userId) — the wizard's
|
||||
// invariant is "one active draft per owner", not
|
||||
// "draft-id-in-custom-id".
|
||||
Assert.Contains("GetActiveAsync(\"Discord\"", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModalHandler_ShouldExtractTextFromLabel()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// The wizard's modals wrap a single TextInput in a Label. The
|
||||
// handler must walk Components[0] (Label) → .Component (TextInput)
|
||||
// → .Value to retrieve the user's text. If this drifts the
|
||||
// modal submit silently becomes a no-op.
|
||||
Assert.Contains("Components[0]", source, StringComparison.Ordinal);
|
||||
Assert.Contains("TextInput", source, StringComparison.Ordinal);
|
||||
Assert.Contains(".Value", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringMenuHandler_ShouldReadSelectedValues()
|
||||
{
|
||||
var source = ReadSource("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs");
|
||||
// StringSelectMenu interactions expose SelectedValues[0]
|
||||
// for our MaxValues=1 menus.
|
||||
Assert.Contains("SelectedValues[0]", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roundtrip the renderer output through the dispatcher's parser to
|
||||
/// prove the wire formats agree. This is a real behavioural test
|
||||
/// (not a string-grep) — it actually constructs the ButtonProperties
|
||||
/// that NetCord would send, strips the [ComponentInteraction("wizard")]
|
||||
/// prefix exactly as NetCord does, and asserts the dispatcher's
|
||||
/// switch would route the click to the right branch. Catches the
|
||||
/// class of "renderer and dispatcher disagree on the wire format"
|
||||
/// regressions that the string-grep tests above cannot detect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Renderer_And_Dispatcher_Agree_On_Wire_Format()
|
||||
{
|
||||
// Choice button: dispatcher expects `btn:choice:<step>:<value>`.
|
||||
var choice = DiscordWizardStep.ChoiceButtonCustomId("Type", "single");
|
||||
Assert.Equal("wizard:btn:choice:Type:single", choice);
|
||||
var choiceArgs = StripWizardPrefix(choice);
|
||||
var choiceParts = choiceArgs.Split(':', 4);
|
||||
Assert.Equal("btn", choiceParts[0]);
|
||||
Assert.Equal("choice", choiceParts[1]);
|
||||
Assert.Equal("Type", choiceParts[2]);
|
||||
Assert.Equal("single", choiceParts[3]);
|
||||
|
||||
// Control button: dispatcher expects `btn:<action>:1`.
|
||||
var cancel = DiscordWizardStep.ControlButtonCustomId("cancel");
|
||||
Assert.Equal("wizard:btn:cancel:1", cancel);
|
||||
var cancelArgs = StripWizardPrefix(cancel);
|
||||
var cancelParts = cancelArgs.Split(':', 3);
|
||||
Assert.Equal("btn", cancelParts[0]);
|
||||
Assert.Equal("cancel", cancelParts[1]);
|
||||
|
||||
// Modal trigger: dispatcher expects `btn:modal:<modalStep>`.
|
||||
var modal = DiscordWizardStep.ModalTriggerButtonCustomId("SystemFreeText");
|
||||
Assert.Equal("wizard:btn:modal:SystemFreeText", modal);
|
||||
var modalArgs = StripWizardPrefix(modal);
|
||||
var modalParts = modalArgs.Split(':', 3);
|
||||
Assert.Equal("btn", modalParts[0]);
|
||||
Assert.Equal("modal", modalParts[1]);
|
||||
Assert.Equal("SystemFreeText", modalParts[2]);
|
||||
|
||||
// All customIds must fit Discord's 100-char limit.
|
||||
Assert.All(
|
||||
new[] { choice, cancel, modal },
|
||||
cid => Assert.True(
|
||||
cid.Length <= DiscordWizardStep.MaxCustomIdLength,
|
||||
$"CustomId '{cid}' exceeds 100 chars: {cid.Length}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Create/Back/Cancel/Resume control buttons in the renderer
|
||||
/// (and in BuildResumeRow) must emit the format the dispatcher's
|
||||
/// switch matches directly — NOT the choice-button format. This
|
||||
/// test parses every button's customId and asserts the dispatcher
|
||||
/// would route it to the right branch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ControlButtons_Are_Parsed_As_Control_Not_Choice()
|
||||
{
|
||||
// Real customIds the renderer / BuildResumeRow emit for control actions.
|
||||
var controlIds = new[]
|
||||
{
|
||||
DiscordWizardStep.ControlButtonCustomId("back"),
|
||||
DiscordWizardStep.ControlButtonCustomId("cancel"),
|
||||
"wizard:btn:create:1",
|
||||
"wizard:btn:resume:continue",
|
||||
"wizard:btn:resume:restart",
|
||||
};
|
||||
|
||||
foreach (var cid in controlIds)
|
||||
{
|
||||
var parts = StripWizardPrefix(cid).Split(':', 3);
|
||||
Assert.Equal("btn", parts[0]);
|
||||
// The dispatcher's switch matches these as parts[1] == "back"|"cancel"|"create"|"resume".
|
||||
// They must NOT be tagged as "choice" (that would route through the wizard
|
||||
// with a nonsensical step name).
|
||||
Assert.NotEqual("choice", parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirror NetCord's [ComponentInteraction("wizard")] prefix strip.</summary>
|
||||
private static string StripWizardPrefix(string customId)
|
||||
{
|
||||
const string prefix = "wizard:";
|
||||
return customId.StartsWith(prefix, StringComparison.Ordinal) ? customId[prefix.Length..] : customId;
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(needle)) return 0;
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += needle.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord.Rest;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
|
||||
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
|
||||
/// a session with no player cap (null in <c>sessions.max_players</c>), the
|
||||
/// same affordance the Telegram wizard provides.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardStepCapacityRenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
|
||||
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
|
||||
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
|
||||
{
|
||||
var draft = new WizardDraft { Step = step };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var buttons = ExtractButtons(render);
|
||||
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
|
||||
Assert.NotNull(noLimit);
|
||||
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.List<string> ExtractButtonLabels(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.Select(b => b.Label ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
|
||||
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
|
||||
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
|
||||
/// <c>0</c>. <c>0</c> would violate the DB CHECK
|
||||
/// <c>ck_sessions_max_players</c> in V006 and the contract that
|
||||
/// <c>null</c> means "no limit".
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
@@ -11,13 +10,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
|
||||
|
||||
public sealed class DiscordLandingPromisesSmokeTests
|
||||
{
|
||||
private sealed record SmokeParseResult(
|
||||
string Title,
|
||||
string Link,
|
||||
int? MaxPlayers,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes);
|
||||
|
||||
[Fact]
|
||||
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
|
||||
{
|
||||
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
|
||||
var parseResult = BuildRecurringSessionParseResult();
|
||||
|
||||
Assert.True(parseResult.IsValid);
|
||||
Assert.Equal(3, parseResult.ScheduledTimes.Count);
|
||||
Assert.Equal(2, parseResult.MaxPlayers);
|
||||
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
|
||||
@@ -126,16 +129,17 @@ public sealed class DiscordLandingPromisesSmokeTests
|
||||
Assert.Contains("Carol", firstSessionEmbed.Description);
|
||||
}
|
||||
|
||||
private static string BuildRecurringSessionCommand() =>
|
||||
string.Join(
|
||||
'\n',
|
||||
"/newsession",
|
||||
"Название: Landing Promise Smoke",
|
||||
"Время: 15.05.2026 19:30",
|
||||
"Игр: 3",
|
||||
"Интервал: 7",
|
||||
"Мест: 2",
|
||||
"Ссылка: https://example.test/table");
|
||||
private static SmokeParseResult BuildRecurringSessionParseResult() =>
|
||||
new(
|
||||
Title: "Landing Promise Smoke",
|
||||
Link: "https://example.test/table",
|
||||
MaxPlayers: 2,
|
||||
ScheduledTimes: new[]
|
||||
{
|
||||
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
|
||||
});
|
||||
|
||||
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
|
||||
actionRows
|
||||
@@ -183,14 +187,14 @@ public sealed class DiscordLandingPromisesSmokeTests
|
||||
public FakeDiscordMessage LastMessage => Messenger.LastMessage;
|
||||
|
||||
public static DiscordLandingSmokeScenario Publish(
|
||||
NewSessionParseResult parseResult,
|
||||
SmokeParseResult parseResult,
|
||||
SessionNotificationMode notificationMode)
|
||||
{
|
||||
var scenario = new DiscordLandingSmokeScenario(
|
||||
parseResult.Title!,
|
||||
parseResult.Title,
|
||||
parseResult.ScheduledTimes,
|
||||
parseResult.MaxPlayers,
|
||||
parseResult.Link!,
|
||||
parseResult.Link,
|
||||
notificationMode);
|
||||
|
||||
scenario.RenderBatch();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
@@ -10,13 +9,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
|
||||
|
||||
public sealed class TelegramLandingPromisesSmokeTests
|
||||
{
|
||||
private sealed record SmokeParseResult(
|
||||
string Title,
|
||||
string Link,
|
||||
int? MaxPlayers,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes);
|
||||
|
||||
[Fact]
|
||||
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
|
||||
{
|
||||
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
|
||||
var parseResult = BuildRecurringSessionParseResult();
|
||||
|
||||
Assert.True(parseResult.IsValid);
|
||||
Assert.Equal(3, parseResult.ScheduledTimes.Count);
|
||||
Assert.Equal(2, parseResult.MaxPlayers);
|
||||
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);
|
||||
}
|
||||
|
||||
private static string BuildRecurringSessionCommand() =>
|
||||
string.Join(
|
||||
'\n',
|
||||
"/newsession",
|
||||
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
|
||||
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
|
||||
"\u0418\u0433\u0440: 3",
|
||||
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
|
||||
"\u041c\u0435\u0441\u0442: 2",
|
||||
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
|
||||
private static SmokeParseResult BuildRecurringSessionParseResult() =>
|
||||
new(
|
||||
Title: "Landing Promise Smoke",
|
||||
Link: "https://example.test/table",
|
||||
MaxPlayers: 2,
|
||||
ScheduledTimes: new[]
|
||||
{
|
||||
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
|
||||
});
|
||||
|
||||
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
|
||||
markup.InlineKeyboard
|
||||
@@ -169,14 +173,14 @@ public sealed class TelegramLandingPromisesSmokeTests
|
||||
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
|
||||
|
||||
public static TelegramLandingSmokeScenario Publish(
|
||||
NewSessionParseResult parseResult,
|
||||
SmokeParseResult parseResult,
|
||||
SessionNotificationMode notificationMode)
|
||||
{
|
||||
var scenario = new TelegramLandingSmokeScenario(
|
||||
parseResult.Title!,
|
||||
parseResult.Title,
|
||||
parseResult.ScheduledTimes,
|
||||
parseResult.MaxPlayers,
|
||||
parseResult.Link!,
|
||||
parseResult.Link,
|
||||
notificationMode);
|
||||
|
||||
scenario.RenderBatch();
|
||||
|
||||
-152
@@ -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);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
|
||||
/// when the wizard payload carries no player limit (e.g. user picked
|
||||
/// «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
|
||||
/// <c>0</c> would violate the DB CHECK constraint
|
||||
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
|
||||
/// <c>NULL</c>, which is the wire-level representation of "no limit".
|
||||
/// </summary>
|
||||
public sealed class CreateSessionHandlerBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
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(long.Parse(draft.ChatId, System.Globalization.CultureInfo.InvariantCulture), 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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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.");
|
||||
}
|
||||
+19
@@ -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.");
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
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);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Confirm, draft.Step);
|
||||
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
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.HandleInteractionAsync(CallbackInteraction(addData, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction(dtString, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(noWaitlist, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(yesWaitlist, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(
|
||||
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(
|
||||
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
// The wizard sees the in-memory slot count > 0 and advances to confirm.
|
||||
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
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 (only explicit no-limit can skip numeric capacity)
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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 NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("single", out var single));
|
||||
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
|
||||
// поэтому null-MaxPlayers просто не пишется. Оба варианта
|
||||
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
|
||||
// и уйдут в БД как NULL — то есть «без лимита».
|
||||
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
|
||||
{
|
||||
Assert.True(
|
||||
maxPlayers.ValueKind == JsonValueKind.Null,
|
||||
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
|
||||
{
|
||||
// A stale waitlist button from Capacity must not move a draft forward
|
||||
// unless MaxPlayers is already set. Otherwise users can reach Confirm
|
||||
// with a missing capacity and get "Не заполнены поля: лимит мест".
|
||||
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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Publish, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
|
||||
Assert.Equal(clubId, clubIdJson.GetGuid());
|
||||
}
|
||||
|
||||
[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.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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(),
|
||||
};
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
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.HandleInteractionAsync(TextInteraction(" ", ownerId: draft.OwnerId), 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', WizardStepLimits.MaxTitleLength + 1);
|
||||
await wizard.HandleInteractionAsync(TextInteraction(tooLong, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("01.01.2020 12:00", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("not a date", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("not a url", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("https://example.com/x.jpg", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("waitlist:on")]
|
||||
[InlineData("waitlist:off")]
|
||||
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
|
||||
{
|
||||
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);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction(input, ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("-", ownerId: draft.OwnerId), 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.HandleInteractionAsync(TextInteraction("CustomSystem", ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Duration, draft.Step);
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
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: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
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: long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user