diff --git a/.env.example b/.env.example index 47db44c..0edcd37 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7 # Имя Docker volume для резервных копий БД BACKUP_VOLUME_NAME=game_pgbackups + +# Имя Docker volume для обложек портфолио (загружаемых мастерами) +PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2246537..7c47119 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.5.1 + VERSION: 3.6.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 22ecae3..1467487 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.5.1 + 3.6.0 net10.0 preview enable diff --git a/README.md b/README.md index e72f852..eadc38d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v3.5.1`. +**Текущая версия:** `v3.6.0`. --- @@ -39,6 +39,9 @@ - **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**. - **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются. - **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers. +- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров. +- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице. +- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`. - **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона. - **📦 Bulk-операции для Batch Sessions**: - обновить общий `title`/`link` у всей пачки; @@ -126,6 +129,32 @@ docker compose up -d 4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord. 5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении. +## 📚 Портфолио завершённых приключений + +Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков. + +### Возможности + +- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`. +- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами. +- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена. +- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу. +- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы. + +### Хранение обложек + +Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`. + +В `.env` можно переопределить имя volume: + +```env +PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers +``` + +В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`). + +Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов. + ## 💾 Backup и восстановление Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose. diff --git a/compose.yaml b/compose.yaml index 6820840..c4fda7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.1 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.6.0 restart: always depends_on: db: @@ -67,11 +67,13 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.1 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.6.0 restart: always depends_on: db: condition: service_healthy + bot: + condition: service_healthy environment: - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}" @@ -84,11 +86,13 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.1 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.6.0 restart: always depends_on: db: condition: service_healthy + bot: + condition: service_healthy environment: - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}" @@ -97,10 +101,12 @@ services: - "Discord__ClientId=${DISCORD_CLIENT_ID:-}" - "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}" - "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}" + - "PortfolioCovers__StoragePath=/app/portfolio-covers" ports: - "${GMRELAY_WEB_PORT:-8080}:8080" volumes: - web_keys:/app/dataprotection-keys + - portfolio_covers:/app/portfolio-covers networks: - gmrelay healthcheck: @@ -116,6 +122,8 @@ volumes: name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys} pgbackups: name: ${BACKUP_VOLUME_NAME:-game_pgbackups} + portfolio_covers: + name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers} networks: gmrelay: diff --git a/docs/c4-system-context.md b/docs/c4-system-context.md index 311a265..6b17562 100644 --- a/docs/c4-system-context.md +++ b/docs/c4-system-context.md @@ -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`. diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md new file mode 100644 index 0000000..76dc286 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -0,0 +1,1649 @@ +# Completed Game Portfolio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add moderated public portfolios of completed adventures with multi-session grouping, uploaded covers, GM-profile and club visibility, and participant-submitted reviews. + +**Architecture:** Add a bounded portfolio vertical slice in `GmRelay.Web`: `IPortfolioStore`/`PortfolioService` own PostgreSQL persistence, `AuthorizedPortfolioService` owns current-user checks and orchestration, and `IPortfolioCoverStorage` isolates local volume storage from a future S3 implementation. Existing `/showcase` recruitment queries remain unchanged. Public Razor pages consume sanitized DTOs only. + +**Tech Stack:** .NET 10, Blazor Server, PostgreSQL, Npgsql, Dapper, DbUp SQL migrations, xUnit, Docker Compose. + +--- + +## File Map + +**Create** + +- `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql` +- `src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs` +- `src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs` +- `src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs` +- `src/GmRelay.Web/Services/Portfolio/PortfolioService.cs` +- `src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs` +- `src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs` +- `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs` +- `src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs` +- `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs` +- `src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor` +- `src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor` +- `src/GmRelay.Web/Components/Pages/PortfolioEditor.razor` +- `src/GmRelay.Web/Components/Pages/PublicPortfolio.razor` +- `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs` +- `tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs` +- `tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs` +- `tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs` + +**Modify** + +- `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs` +- `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs` +- `src/GmRelay.AppHost/Program.cs` +- `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` +- `tests/GmRelay.Bot.Tests/packages.lock.json` +- `src/GmRelay.Web/Program.cs` +- `src/GmRelay.Web/appsettings.Development.json` +- `src/GmRelay.Web/Dockerfile` +- `src/GmRelay.Web/Components/Pages/GroupDetails.razor` +- `src/GmRelay.Web/Components/Pages/SessionHistory.razor` +- `src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor` +- `src/GmRelay.Web/Components/Pages/PublicClub.razor` +- `src/GmRelay.Web/wwwroot/app.css` +- `.env.example` +- `compose.yaml` +- `README.md` +- `docs/c4-system-context.md` +- `Directory.Build.props` +- `.gitea/workflows/deploy.yml` +- `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +--- + +### Task 1: Add Portfolio Schema + +**Quality-review fix index** + +- `d591e5e` `fix(data): protect portfolio publication invariant` +- `3c1a98b` `fix(data): harden portfolio publication concurrency` +- `76b3ff7` `fix(data): serialize portfolio publication validation` +- `6e7a0cb` `fix(data): enforce portfolio validation isolation` +- `f493836` `fix(data): reject stale portfolio trigger snapshots` +- `da0a306` `fix(data): enforce completed portfolio sessions` +- `a28b75d` `fix(data): align portfolio mutation lock order` +- `d762ecc` `fix(data): serialize portfolio future reschedules` +- `1d62f69` `fix(data): lock racing portfolio publications` +- `ea71448` `fix(data): serialize new-link publication races` +- `1a81610` `fix(data): reject stale reschedule snapshots` +- `a20da4b` `fix(data): serialize portfolio mutations before rows` + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs` +- Create: `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql` +- Modify: `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs` +- Modify: `src/GmRelay.AppHost/Program.cs` +- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` +- Modify: `tests/GmRelay.Bot.Tests/packages.lock.json` +- Modify: `compose.yaml` + +- [ ] **Step 1: Write the failing migration and session-deletion source-contract tests** + +Add tests that read `V029__add_completed_game_portfolios_and_reviews.sql` and assert: + +```csharp +[Fact] +public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards() +{ + var migration = await ReadRepositoryFileAsync( + "src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + var normalizedMigration = NormalizeSql(migration); + + Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_reviews", migration, StringComparison.Ordinal); + Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal); + Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal); + Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal); + Assert.Contains("'Pending', 'Approved', 'Rejected', 'Hidden'", migration, StringComparison.Ordinal); + Assert.Contains("publication_consent_at", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_game_reviews_public", migration, StringComparison.Ordinal); + Assert.Contains("format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE UNIQUE INDEX ux_portfolio_games_public_slug ON portfolio_games (lower(public_slug)) WHERE public_slug IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION lock_portfolio_publication_mutation() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_games_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation BEFORE DELETE OR UPDATE OF scheduled_at ON sessions FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON game_groups FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON players FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); +} +``` + +Add a second test asserting the public-card columns are provider-neutral: + +```csharp +[Fact] +public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys() +{ + var migration = await ReadRepositoryFileAsync( + "src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + + Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal); + Assert.DoesNotContain("s3_bucket", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase); +} +``` + +Add `PortfolioSessionDeletionSourceTests.cs`. Normalize whitespace before comparing source text and assert that both session-deletion paths acquire the portfolio mutation lock, explicitly lock the target session row, unpublish linked cards, and then delete the required session link: + +```csharp +[Fact] +public async Task SharedDeleteSessionHandler_ShouldLockSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession() +{ + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs")); + + const string sessionLock = + "FROM sessions s WHERE s.id = @SessionId FOR UPDATE OF s"; + const string mutationLock = + "SELECT pg_advisory_xact_lock(20260530, 108)"; + const string unpublish = + "UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = @SessionId AND pg.is_public = true"; + + Assert.Contains(mutationLock, source, StringComparison.Ordinal); + Assert.Contains(sessionLock, source, StringComparison.Ordinal); + Assert.Contains(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(mutationLock, StringComparison.Ordinal) < + source.IndexOf(sessionLock, StringComparison.Ordinal)); + Assert.True( + source.IndexOf(sessionLock, StringComparison.Ordinal) < + source.IndexOf(unpublish, StringComparison.Ordinal)); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal)); +} + +[Fact] +public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession() +{ + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs")); + + const string sessionLock = + "SELECT s.id FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId FOR UPDATE OF s"; + const string unpublish = + "UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true"; + + Assert.Contains(sessionLock, source, StringComparison.Ordinal); + Assert.Contains(unpublish, source, StringComparison.Ordinal); + Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(sessionLock, StringComparison.Ordinal) < + source.IndexOf(unpublish, StringComparison.Ordinal)); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal)); +} +``` + +Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: use database resource name `.AddDatabase("gmrelaydb")`, save the `bot` project resource to a variable, expose its named port `8081` HTTP endpoint with `isProxied: false`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate without a proxy competing with its `HttpListener`. + +- [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** + +Add the package reference: + +```xml + +``` + +Update the locked dependency graph: + +```powershell +dotnet restore tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --use-lock-file +``` + +Create `PortfolioMigrationPostgresFixture.cs` with a shared `PostgreSqlContainer` built from `postgres:17-alpine`. For each test, create a fresh database and apply migration files `V001` through `V029` in ordinal filename order. + +Create `PortfolioMigrationPostgresTests.cs` with these executable scenarios: + +```csharp +[Fact] +public async Task MigrationsV001ThroughV029_ShouldApplyToPostgres17() + +[Theory] +[InlineData("portfolio_game_sessions")] +[InlineData("portfolio_game_masters")] +public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(string linkTable) + +[Fact] +public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt() + +[Theory] +[InlineData(true)] +[InlineData(false)] +public async Task ConcurrentPublishAndLinkDelete_ShouldSerializeBeforeRowsAndRejectInvalidPublicCard(bool publishMutatesFirst) + +[Theory] +[InlineData("portfolio_game_sessions", "session_id")] +[InlineData("portfolio_game_masters", "player_id")] +public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidPublicCard(string linkTable, string linkColumn) + +[Theory] +[InlineData("portfolio_game_sessions", "session_id")] +[InlineData("portfolio_game_masters", "player_id")] +public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(string linkTable, string linkColumn) + +[Theory] +[InlineData(true)] +[InlineData(false)] +public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard(bool publishCommitsFirst) + +[Fact] +public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt() + +[Fact] +public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt() + +[Fact] +public async Task ConcurrentBatchFutureReschedules_ShouldSerializeBeforeSessionRowsWithoutDeadlock() + +[Fact] +public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() + +[Fact] +public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard() + +[Fact] +public async Task ConcurrentNewLinkPublishAndFutureReschedule_ShouldNotCommitInvalidPublicCard() + +[Fact] +public async Task PortfolioSessionLinkInsert_ShouldAcquirePublicationLockBeforeRows() + +[Fact] +public async Task FutureReschedule_ShouldAcquirePublicationLockBeforeSessionRows() + +[Theory] +[InlineData(true)] +[InlineData(false)] +public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeMutationGateBeforeRowsWithoutDeadlock(bool deleteMutatesFirst) + +[Fact] +public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard() + +[Theory] +[InlineData("portfolio_game_sessions")] +[InlineData("portfolio_game_masters")] +public async Task MovingLastRequiredLinkAway_ShouldFailCommitForPublishedCard(string linkTable) + +[Theory] +[InlineData("sessions")] +[InlineData("players")] +public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(string parentTable) + +[Fact] +public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() +``` + +The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A final future reschedule must atomically unpublish linked public cards while preserving their first `published_at`; `past -> future -> past` in one transaction must leave the card public. Opposing-order batch reschedules must prove with `pg_blocking_pids` observation and bounded timeouts that the shared mutation lock serializes statements before session rows, complete without deadlock, and leave both cards private; do not rely on `pg_sleep` timing. The `READ COMMITTED` concurrency scenarios must launch bounded tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. A session-delete versus future-reschedule race must use the common advisory-lock then `sessions` then `portfolio_games` lock order, cover both first-mutation-lock orders through real blocking transactions, and finish with the card private and session deleted. Link insertion and final-future reschedule gate scenarios must prove that invariant-affecting statements acquire the shared mutation lock before rows. The publish/reschedule races must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders and stale-snapshot final-future reschedules after a newly linked publication, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. + +- [ ] **Step 3: Run the Task 1 tests to verify RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" +``` + +Expected during this Task 1 quality-review fix: FAIL because session-deletion handlers do not yet lock `sessions` before linked cards and the Aspire AppHost does not yet attach the bot HTTP health check used by `.WaitFor(bot)`. + +- [ ] **Step 4: Add migration V029** + +Create the migration with these exact tables and indexes: + +```sql +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'; +``` + +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. Immediate statement triggers acquire one transaction-level advisory lock 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. The intentionally global lock is appropriate for low-volume portfolio and schedule writes: under the application default `READ COMMITTED` isolation level it establishes one advisory-lock then row-lock protocol, prevents write-skew across distinct child links, and removes card/advisory and session/advisory inversions. At transaction commit validators re-acquire the same lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger re-reads the final session row, skips intermediate future values that end in the past, and rejects final-future reschedules outside `READ COMMITTED` with `0A000`. Under `READ COMMITTED`, it locks all cards linked to any final-future session in `portfolio_games.id` order, re-acquires the shared advisory lock, and runs one guarded public-card unpublish update with a fresh statement snapshot. The row-lock phase deliberately includes committed drafts so a concurrent draft-to-public publication cannot pass validation against the pre-reschedule session snapshot and commit afterward. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same advisory-lock then `sessions` then `portfolio_games` order: explicitly acquire the mutation lock, lock the target session row, unpublish linked cards, then delete the session. + +- [ ] **Step 5: Lock sessions before explicitly unpublishing linked cards in both session-deletion handlers** + +In `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`, acquire `pg_advisory_xact_lock(20260530, 108)` immediately after starting the transaction, then strengthen the initial session fetch with `FOR UPDATE OF s`. After authorization, run this statement inside the existing transaction before `DELETE FROM sessions`: + +```sql +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 +``` + +In `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`, start a transaction before deleting and acquire `pg_advisory_xact_lock(20260530, 108)`. Lock the guild-scoped target session row with `SELECT s.id ... FOR UPDATE OF s`, preserving the existing not-found result. Run this guild-scoped unpublish statement before the existing guild-scoped `DELETE FROM sessions`, then commit: + +```sql +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 +``` + +Both handlers deliberately use advisory-lock then `sessions` then `portfolio_games` locking before session deletion. This matches future rescheduling, keeps normal deletes successful, preserves the first-publication `published_at`, and leaves the triggers as the direct-SQL and concurrency backstop. + +Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization. + +In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: use `.AddDatabase("gmrelaydb")` to match application connection-string configuration, save the `bot` project resource, add `.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator or binding an Aspire proxy to the bot `HttpListener` port. + +- [ ] **Step 6: Run the Task 1 tests to verify GREEN** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" +``` + +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after final future reschedule, preserved public state after `past -> future -> past`, statement-level mutation locking before session and required-link rows, opposing-order batch reschedules without card deadlock, bounded `READ COMMITTED` publish/delete in both commit orders, existing-link and new-link publish/reschedule races, session-delete/reschedule serialization in both first-lock orders, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating with a non-proxied bot endpoint and matching `gmrelaydb` resource name. + +- [ ] **Step 7: Commit** + +```powershell +git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs src/GmRelay.AppHost/Program.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +git commit -m "fix(data): serialize portfolio future reschedules" +``` + +--- + +### Task 2: Define Portfolio Contracts And Validation + +**Files:** +- Create: `src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs` +- Create: `src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs` +- Create: `src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs` + +- [ ] **Step 1: Write failing privacy and validation tests** + +Add reflection tests that assert `PublicPortfolioCard`, `PublicPortfolioGame`, `PublicPortfolioMaster`, and `PublicPortfolioReview` do not expose names containing: + +```csharp +var forbidden = new[] +{ + "Id", "External", "Telegram", "Discord", "Moderator", + "StorageKey", "PhysicalPath", "JoinLink", "Session" +}; +``` + +Add validation tests: + +```csharp +[Theory] +[InlineData(" Dragon Heist ", "dragon-heist")] +[InlineData("dragon_heist", "dragon-heist")] +public void NormalizeSlug_ShouldReturnCanonicalSlug(string input, string expected) +{ + Assert.Equal(expected, PortfolioValidation.NormalizeSlug(input)); +} + +[Theory] +[InlineData("")] +[InlineData("ab")] +[InlineData("spaces are fine after normalization but this slug is intentionally far too long to be accepted because it exceeds the maximum portfolio slug size of one hundred and sixty characters")] +[InlineData("кириллица")] +public void NormalizeSlug_ShouldRejectInvalidSlug(string input) +{ + Assert.Throws(() => PortfolioValidation.NormalizeSlug(input)); +} + +[Theory] +[InlineData("")] +[InlineData(" ")] +public void NormalizeReviewBody_ShouldRejectBlankText(string body) +{ + Assert.Throws(() => PortfolioValidation.NormalizeReviewBody(body)); +} +``` + +- [ ] **Step 2: Run Task 2 tests to verify RED** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioContractsTests|FullyQualifiedName~PortfolioValidationTests" +``` + +Expected: FAIL because the portfolio contracts and validation helper do not exist. + +- [ ] **Step 3: Add contracts and interface** + +Define these public sanitized records in `PortfolioContracts.cs`: + +```csharp +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 Masters, + IReadOnlyList Reviews); +``` + +Define protected records with IDs for editing: + +```csharp +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 Sessions, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PortfolioGameUpdate( + string Title, string? PublicSlug, string? Description, string? System, string? Format, + IReadOnlyCollection SessionIds, IReadOnlyCollection MasterPlayerIds); + +public enum PortfolioReviewSubmissionState +{ + RequiresAuthentication, + Ineligible, + Eligible, + AlreadySubmitted +} +``` + +Define `IPortfolioStore` with: + +```csharp +Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug); +Task> GetPublicPortfolioGamesForClubAsync(string clubSlug); +Task GetPublicPortfolioGameBySlugAsync(string slug); +Task> GetPortfolioGamesForGroupAsync(Guid groupId); +Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId); +Task GetPortfolioGameForManagementAsync(Guid portfolioGameId); +Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId); +Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId); +Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId); +Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update); +Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey); +Task 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 GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId); +Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body); +``` + +- [ ] **Step 4: Add validation helper** + +Implement: + +```csharp +public static string NormalizeSlug(string? value) +``` + +Rules: trim, lowercase invariant, replace spaces and underscores with `-`, collapse repeated `-`, trim `-`, require length `3..160`, require regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`. + +Implement: + +```csharp +public static string NormalizeTitle(string? value) +``` + +Rules: trim, require length `2..255`. + +Implement: + +```csharp +public static string? NormalizeDescription(string? value) +``` + +Rules: null for whitespace, otherwise trim, maximum `5000`. + +Implement: + +```csharp +public static string NormalizeReviewBody(string? value) +``` + +Rules: trim, require length `10..2000`. + +Implement: + +```csharp +public static string? NormalizeFormat(string? value) +``` + +Rules: null for whitespace; otherwise accept only `Online`, `Offline`, `Hybrid`. + +- [ ] **Step 5: Run Task 2 tests to verify GREEN** + +Run the Task 2 command again. Expected: PASS. + +- [ ] **Step 6: Commit** + +```powershell +git add src/GmRelay.Web/Services/Portfolio tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs +git commit -m "feat(web): define portfolio contracts and validation" +``` + +--- + +### Task 3: Add Local Cover Storage Behind An S3-Ready Interface + +**Files:** +- Create: `src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs` +- Create: `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs` +- Create: `src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs` +- Create: `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs` +- Modify: `src/GmRelay.Web/Program.cs` +- Modify: `src/GmRelay.Web/appsettings.Development.json` +- Modify: `src/GmRelay.Web/Dockerfile` +- Modify: `.env.example` +- Modify: `compose.yaml` + +- [ ] **Step 1: Write failing storage tests** + +Cover these cases with a temporary directory: + +```csharp +[Fact] +public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey() +{ + var storage = CreateStorage(); + await using var stream = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]); + + var result = await storage.SaveAsync(stream, "image/png"); + + Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal); + Assert.StartsWith("/portfolio-covers/", storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal); + Assert.True(File.Exists(Path.Combine(storagePath, result.StorageKey))); +} + +[Theory] +[InlineData("image/jpeg")] +[InlineData("image/png")] +[InlineData("image/webp")] +public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType) +{ + var storage = CreateStorage(); + await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]); + + await Assert.ThrowsAsync( + () => storage.SaveAsync(stream, contentType)); +} +``` + +Also test a stream larger than `LocalPortfolioCoverStorage.MaxBytes`, invalid delete keys such as `../escape.png`, valid delete, JPEG signature, and WebP `RIFF....WEBP` signature. + +Add source-contract wiring tests: + +```csharp +Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal); +Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal); +Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal); +Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal); +Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal); +Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal); +Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Run storage tests to verify RED** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~LocalPortfolioCoverStorageTests|FullyQualifiedName~PortfolioCoverRuntimeWiringTests" +``` + +Expected: FAIL because storage types do not exist. + +- [ ] **Step 3: Implement cover storage** + +Define: + +```csharp +public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType); + +public interface IPortfolioCoverStorage +{ + Task SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default); + Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default); + string GetPublicPath(string storageKey); +} + +public sealed class PortfolioCoverStorageOptions +{ + public const string SectionName = "PortfolioCovers"; + public string StoragePath { get; set; } = ""; +} +``` + +Implement `LocalPortfolioCoverStorage` with: + +- `public const long MaxBytes = 5 * 1024 * 1024;` +- normalized extensions `.jpg`, `.png`, `.webp`; +- signature checks: JPEG `FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, WebP `RIFF` plus `WEBP`; +- generated key `$"{Guid.NewGuid():N}{extension}"`; +- safe key regex `^[a-f0-9]{32}\.(jpg|png|webp)$`; +- temporary file write, validation before final `File.Move`; +- cleanup of the temporary file in `finally`; +- public path `/portfolio-covers/{Uri.EscapeDataString(storageKey)}`. + +In `PortfolioCoverStorageExtensions.cs`, add: + +```csharp +public static IServiceCollection AddPortfolioCoverStorage( + this IServiceCollection services, + IConfiguration configuration) + +public static WebApplication UsePortfolioCoverFiles(this WebApplication app) +``` + +`AddPortfolioCoverStorage` configures `PortfolioCoverStorageOptions` and registers `IPortfolioCoverStorage`. `UsePortfolioCoverFiles` resolves relative paths against `app.Environment.ContentRootPath`, creates the directory, and attaches `UseStaticFiles` with `PhysicalFileProvider`, request path `/portfolio-covers`, known image extensions only, and immutable cache headers. + +- [ ] **Step 4: Register configuration, static delivery, and Docker volume** + +In `Program.cs`: + +```csharp +builder.Services.AddPortfolioCoverStorage(builder.Configuration); +``` + +After security headers and before authentication, add: + +```csharp +app.UsePortfolioCoverFiles(); +``` + +In development settings add: + +```json +"PortfolioCovers": { + "StoragePath": "../../artifacts/portfolio-covers" +} +``` + +In `compose.yaml`, mount: + +```yaml +- "PortfolioCovers__StoragePath=/app/portfolio-covers" +``` + +and: + +```yaml +- portfolio_covers:/app/portfolio-covers +``` + +Declare: + +```yaml +portfolio_covers: + name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers} +``` + +Document `PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers` in `.env.example`. + +In `src/GmRelay.Web/Dockerfile`, create and chown both runtime directories before `USER $APP_UID`: + +```dockerfile +RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \ + && chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers +``` + +- [ ] **Step 5: Run storage tests and build to verify GREEN** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~LocalPortfolioCoverStorageTests|FullyQualifiedName~PortfolioCoverRuntimeWiringTests" +dotnet build src/GmRelay.Web/GmRelay.Web.csproj +``` + +Expected: PASS and build succeeds with zero warnings. + +- [ ] **Step 6: Commit** + +```powershell +git add src/GmRelay.Web/Services/Portfolio/Covers src/GmRelay.Web/Program.cs src/GmRelay.Web/appsettings.Development.json src/GmRelay.Web/Dockerfile .env.example compose.yaml tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs +git commit -m "feat(web): add local portfolio cover storage" +``` + +--- + +### Task 4: Implement Portfolio Persistence + +**Files:** +- Create: `src/GmRelay.Web/Services/Portfolio/PortfolioService.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs` +- Modify: `src/GmRelay.Web/Program.cs` + +- [ ] **Step 1: Write failing SQL source-contract tests** + +Assert that `PortfolioService.cs` contains: + +```csharp +Assert.Contains("portfolio_games", source, StringComparison.Ordinal); +Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal); +Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal); +Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal); +Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal); +Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal); +Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal); +Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal); +Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal); +``` + +Add scoped assertions against the public-master query: + +```csharp +Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal); +Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal); +``` + +Add scoped assertions against the public-club query: + +```csharp +Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal); +``` + +Add a regression assertion by reading `SessionService.cs`: + +```csharp +Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Run source-contract tests to verify RED** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioServiceSourceTests" +``` + +Expected: FAIL because `PortfolioService.cs` does not exist. + +- [ ] **Step 3: Implement public reads** + +Create `PortfolioService(NpgsqlDataSource dataSource, IPortfolioCoverStorage coverStorage) : IPortfolioStore`. + +Implement: + +```csharp +GetPublicPortfolioGamesForMasterAsync(string masterSlug) +GetPublicPortfolioGamesForClubAsync(string clubSlug) +GetPublicPortfolioGameBySlugAsync(string slug) +``` + +Rules: + +- Filter `portfolio_games.is_public = true`. +- Master query joins `portfolio_game_masters` and public `master_profiles` by slug but does not require `game_groups.public_schedule_enabled`. +- Club query joins `game_groups` and requires `public_schedule_enabled = true` plus public club slug. +- Detail query returns club name and slug only when the club page is public. +- Detail query loads selected public masters separately. +- Detail query loads only consented reviews with `moderation_status = 'Approved'`. +- Convert `cover_storage_key` to a public URL with `coverStorage.GetPublicPath`. +- Public DTOs never carry private UUIDs. + +- [ ] **Step 4: Implement protected reads and writes** + +Implement: + +```csharp +GetPortfolioGamesForGroupAsync +GetPortfolioGameGroupIdAsync +GetPortfolioGameForManagementAsync +GetEligibleCompletedSessionsAsync +GetPortfolioMasterOptionsAsync +CreatePortfolioDraftAsync +UpdatePortfolioDraftAsync +SetPortfolioCoverAsync +DeletePortfolioGameAsync +SetPortfolioPublicationAsync +ModeratePortfolioReviewAsync +``` + +Rules: + +- Draft creation optionally links one session only if it belongs to the same group, is in the past, and is not linked elsewhere. +- Update runs in one transaction, locks the portfolio row, updates scalar fields, unpublishes the card before replacing required child links, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club. +- Cover replacement returns the prior storage key after the database update. +- Delete returns the cover key after deleting the row. +- Publishing locks the row and verifies slug, description, cover key, one or more linked sessions, every linked session has `scheduled_at < now()`, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. The deferred database guard is a backstop for direct SQL and concurrent changes. +- Unpublishing only sets `is_public = false`. +- Moderation accepts `Approved`, `Rejected`, or `Hidden`, stores moderator ID and timestamp, and scopes the review to the managed adventure. + +- [ ] **Step 5: Implement authenticated review methods** + +Implement: + +```csharp +GetReviewSubmissionStateAsync +SubmitPortfolioReviewAsync +``` + +Rules: + +- Resolve linked player identities using the same `player_links` direction as `SessionService.ResolveEffectivePlayerIdAsync`. +- Eligible means the public adventure has at least one linked past session with a matching `session_participants.player_id`, `sp.is_gm = false`, and `sp.registration_status = 'Active'`. +- Existing review returns `AlreadySubmitted`. +- Missing eligible participation returns `Ineligible`. +- Insert starts with `Pending`, stores trimmed text and the display-name snapshot, and uses `ON CONFLICT ... DO NOTHING` to reject duplicates. + +- [ ] **Step 6: Register portfolio store** + +In `Program.cs` add: + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 7: Run tests and build to verify GREEN** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioServiceSourceTests" +dotnet build src/GmRelay.Web/GmRelay.Web.csproj +``` + +Expected: PASS and build succeeds with zero warnings. + +- [ ] **Step 8: Commit** + +```powershell +git add src/GmRelay.Web/Services/Portfolio/PortfolioService.cs src/GmRelay.Web/Program.cs tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs +git commit -m "feat(web): add portfolio persistence" +``` + +--- + +### Task 5: Add Authorized Portfolio Orchestration + +**Files:** +- Create: `src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs` +- Modify: `src/GmRelay.Web/Program.cs` + +- [ ] **Step 1: Write failing authorization tests** + +Use small fake implementations of `IPortfolioStore`, `ISessionStore`, and `IPortfolioCoverStorage`. + +Cover: + +```csharp +[Fact] +public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm() +{ + var service = CreateService(isManager: true); + var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId); + Assert.Equal(draftId, created); +} + +[Fact] +public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager() +{ + var service = CreateService(isManager: false); + await Assert.ThrowsAsync( + () => service.CreateDraftForCurrentUserAsync(groupId, null)); +} + +[Fact] +public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap() +{ + var service = CreateService(isManager: true, oldStorageKey: "old.png"); + await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, content, "image/png"); + Assert.Contains("old.png", fakeStorage.DeletedKeys); +} + +[Fact] +public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails() +{ + var service = CreateService(isManager: true, throwOnSetCover: true); + await Assert.ThrowsAsync( + () => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, content, "image/png")); + Assert.Contains("new.png", fakeStorage.DeletedKeys); +} +``` + +Also test: unauthorized editor read, unauthorized update, unauthorized moderation, delete cleanup, anonymous review state, review body normalization, slug normalization, publication call, and moderator effective-player resolution. + +- [ ] **Step 2: Run authorization tests to verify RED** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~AuthorizedPortfolioServiceTests" +``` + +Expected: FAIL because `AuthorizedPortfolioService` does not exist. + +- [ ] **Step 3: Implement authorized wrapper** + +Create: + +```csharp +public sealed class AuthorizedPortfolioService( + IPortfolioStore portfolioStore, + ISessionStore sessionStore, + IPortfolioCoverStorage coverStorage, + IHttpContextAccessor httpContextAccessor) +``` + +Implement management methods: + +```csharp +GetPortfolioGamesForCurrentUserAsync(Guid groupId) +GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId) +GetCompletedSessionsForCurrentUserAsync(Guid groupId) +CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId) +UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update) +ReplaceCoverForCurrentUserAsync(Guid portfolioGameId, Stream content, string contentType, CancellationToken cancellationToken = default) +DeleteForCurrentUserAsync(Guid portfolioGameId) +SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic) +ModerateReviewForCurrentUserAsync(Guid portfolioGameId, Guid reviewId, string moderationStatus) +``` + +Implement review methods: + +```csharp +GetReviewSubmissionStateForCurrentUserAsync(string slug) +SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent) +``` + +Rules: + +- Every management method checks `ISessionStore.IsGroupManagerAsync`. +- `GetCompletedSessionsForCurrentUserAsync` returns `IPortfolioStore.GetEligibleCompletedSessionsAsync(groupId, null)` only after the same manager check. +- Resolve the owning group through `GetPortfolioGameGroupIdAsync` before loading private editor data or applying any ID-scoped mutation. +- `UpdateDraftForCurrentUserAsync` applies `PortfolioValidation` to title, slug, description, and format. +- Reject review submission unless the consent checkbox is true. +- Cover replacement stores the new cover first, updates the database second, deletes the old cover only after the swap, and cleans up the new cover when persistence fails. +- Delete removes the database row first and deletes the cover second. + +- [ ] **Step 4: Register scoped service** + +In `Program.cs`: + +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 5: Run tests and build to verify GREEN** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~AuthorizedPortfolioServiceTests" +dotnet build src/GmRelay.Web/GmRelay.Web.csproj +``` + +Expected: PASS and build succeeds with zero warnings. + +- [ ] **Step 6: Commit** + +```powershell +git add src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs src/GmRelay.Web/Program.cs tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs +git commit -m "feat(web): authorize portfolio management and reviews" +``` + +--- + +### Task 6: Add Protected Portfolio Management UI + +**Files:** +- Create: `src/GmRelay.Web/Components/Pages/PortfolioEditor.razor` +- Create: `src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs` +- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor` +- Modify: `src/GmRelay.Web/Components/Pages/SessionHistory.razor` +- Modify: `src/GmRelay.Web/wwwroot/app.css` + +- [ ] **Step 1: Write failing protected-page source tests** + +Assert: + +```csharp +Assert.Contains("@page \"/portfolio/manage/{PortfolioGameId:guid}\"", editor, StringComparison.Ordinal); +Assert.Contains("@attribute [Authorize]", editor, StringComparison.Ordinal); +Assert.Contains("InputFile", editor, StringComparison.Ordinal); +Assert.Contains("ReplaceCoverForCurrentUserAsync", editor, StringComparison.Ordinal); +Assert.Contains("SetPublicationForCurrentUserAsync", editor, StringComparison.Ordinal); +Assert.Contains("ModerateReviewForCurrentUserAsync", editor, StringComparison.Ordinal); +Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal); +Assert.Contains("@page \"/group/{GroupId:guid}/completed\"", completedSessions, StringComparison.Ordinal); +Assert.Contains("@attribute [Authorize]", completedSessions, StringComparison.Ordinal); +Assert.Contains("GetCompletedSessionsForCurrentUserAsync", completedSessions, StringComparison.Ordinal); +Assert.Contains("CreateDraftForCurrentUserAsync", completedSessions, StringComparison.Ordinal); +Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal); +Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Run page tests to verify RED** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests" +``` + +Expected: FAIL because protected portfolio UI is absent. + +- [ ] **Step 3: Extend group management page** + +Inject `AuthorizedPortfolioService`. Load summaries after the existing group authorization succeeds. Add a section with: + +- heading `Проведённые приключения`; +- create button calling `CreateDraftForCurrentUserAsync(GroupId, null)` and navigating to `/portfolio/manage/{id}`; +- link to `/group/{GroupId}/completed`; +- rows for title, draft/public badge, linked-session count, GM count, pending-review count, and edit link. + +- [ ] **Step 4: Add completed-session list** + +Create `GroupCompletedSessions.razor`: + +- authorized route `/group/{GroupId:guid}/completed`; +- load rows through `GetCompletedSessionsForCurrentUserAsync`; +- show past session title and Moscow date; +- provide history links; +- provide `Добавить в портфолио` buttons calling `CreateDraftForCurrentUserAsync(GroupId, session.Id)` and navigating to `/portfolio/manage/{id}`; +- render a compact empty state when the list is empty. + +- [ ] **Step 5: Add completed-session quick action** + +In `SessionHistory.razor`, inject `AuthorizedPortfolioService`. If the loaded session has `ScheduledAt < DateTime.UtcNow`, render `Добавить в портфолио`. On click call: + +```csharp +var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId); +Navigation.NavigateTo($"/portfolio/manage/{portfolioId}"); +``` + +- [ ] **Step 6: Add protected editor** + +Create `PortfolioEditor.razor`: + +- authorized route `/portfolio/manage/{PortfolioGameId:guid}`; +- load editor via `GetPortfolioGameForCurrentUserAsync`; +- edit title, slug, description, system, and format; +- render checkbox lists for completed sessions and GMs; +- save through `UpdateDraftForCurrentUserAsync`; +- upload one `IBrowserFile` with `OpenReadStream(LocalPortfolioCoverStorage.MaxBytes)` and `ReplaceCoverForCurrentUserAsync`; +- publish/unpublish through `SetPublicationForCurrentUserAsync`; +- delete through `DeleteForCurrentUserAsync`; +- render moderation rows and buttons `Одобрить`, `Отклонить`, `Скрыть`. + +- [ ] **Step 7: Add protected UI styles** + +Add `.portfolio-management-list`, `.portfolio-editor-grid`, `.portfolio-option-list`, `.portfolio-review-moderation`, and mobile layout rules to `app.css`. + +- [ ] **Step 8: Run Task 6 tests and build to verify GREEN** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests" +dotnet build src/GmRelay.Web/GmRelay.Web.csproj +``` + +Expected: PASS and build succeeds with zero warnings. + +- [ ] **Step 9: Commit** + +```powershell +git add src/GmRelay.Web/Components/Pages/PortfolioEditor.razor src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor src/GmRelay.Web/Components/Pages/GroupDetails.razor src/GmRelay.Web/Components/Pages/SessionHistory.razor src/GmRelay.Web/wwwroot/app.css tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs +git commit -m "feat(web): add portfolio management UI" +``` + +--- + +### Task 7: Add Public Portfolio Pages And Review Form + +**Files:** +- Create: `src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor` +- Create: `src/GmRelay.Web/Components/Pages/PublicPortfolio.razor` +- Modify: `src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor` +- Modify: `src/GmRelay.Web/Components/Pages/PublicClub.razor` +- Modify: `src/GmRelay.Web/wwwroot/app.css` +- Modify: `tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs` + +- [ ] **Step 1: Add failing public-page source tests** + +Assert: + +```csharp +Assert.Contains("@page \"/portfolio/{Slug}\"", publicPortfolio, StringComparison.Ordinal); +Assert.Contains("@layout PublicLayout", publicPortfolio, StringComparison.Ordinal); +Assert.DoesNotContain("@attribute [Authorize]", publicPortfolio, StringComparison.Ordinal); +Assert.Contains("GetPublicPortfolioGameBySlugAsync", publicPortfolio, StringComparison.Ordinal); +Assert.Contains("SubmitReviewForCurrentUserAsync", publicPortfolio, StringComparison.Ordinal); +Assert.Contains("publicationConsent", publicPortfolio, StringComparison.Ordinal); +Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal); +Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal); +Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal); +Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal); +Assert.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal); +Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Run public-page tests to verify RED** + +Run the Task 6 page-test command. Expected: FAIL on missing public portfolio page and card grid. + +- [ ] **Step 3: Add reusable public card grid** + +Create `PortfolioCardGrid.razor` with parameter: + +```csharp +[Parameter, EditorRequired] +public IReadOnlyList Games { get; set; } = []; +``` + +Each card renders cover, title, completion date, optional system/format badges, and `/portfolio/{Slug}` link. + +- [ ] **Step 4: Extend public GM and club pages** + +- Inject `IPortfolioStore`. +- Load master cards with `GetPublicPortfolioGamesForMasterAsync(Slug.Trim())`. +- Load club cards with `GetPublicPortfolioGamesForClubAsync(Slug.Trim())`. +- Render `PortfolioCardGrid` below existing upcoming-session content when cards exist. +- Keep the public club portfolio tied to the existing public-club route; keep GM portfolio independent from club visibility. + +- [ ] **Step 5: Add public portfolio detail and conditional review form** + +Create `PublicPortfolio.razor`: + +- load sanitized detail with `GetPublicPortfolioGameBySlugAsync`; +- load current-user submission state through `AuthorizedPortfolioService`; +- render cover hero, description, completion date, system, format, optional club link, GM links, and approved reviews; +- for `Eligible`, show textarea and required consent checkbox; +- for `AlreadySubmitted`, show `Отзыв отправлен на модерацию`; +- for `Ineligible`, show a short non-sensitive explanation; +- for `RequiresAuthentication`, show sign-in link; +- submit through `SubmitReviewForCurrentUserAsync`. + +- [ ] **Step 6: Add public styles** + +Add `.portfolio-grid`, `.portfolio-card`, `.portfolio-card-cover`, `.portfolio-cover-hero`, `.portfolio-review-list`, `.portfolio-review-card`, and responsive rules to `app.css`. + +- [ ] **Step 7: Run page tests and build to verify GREEN** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests" +dotnet build src/GmRelay.Web/GmRelay.Web.csproj +``` + +Expected: PASS and build succeeds with zero warnings. + +- [ ] **Step 8: Commit** + +```powershell +git add src/GmRelay.Web/Components/Portfolio src/GmRelay.Web/Components/Pages/PublicPortfolio.razor src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor src/GmRelay.Web/Components/Pages/PublicClub.razor src/GmRelay.Web/wwwroot/app.css tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs +git commit -m "feat(web): publish completed game portfolios" +``` + +--- + +### Task 8: Update Documentation And Release Version + +**Files:** +- Modify: `README.md` +- Modify: `docs/c4-system-context.md` +- Modify: `Directory.Build.props` +- Modify: `compose.yaml` +- Modify: `.gitea/workflows/deploy.yml` +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` +- Modify: `tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs` + +- [ ] **Step 1: Update version regression test first** + +Change the expected UI version in `CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion` from `v3.5.1` to: + +```csharp +Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal); +``` + +- [ ] **Step 2: Run version test to verify RED** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~NavMenu_ShouldExposeCurrentProjectVersion" +``` + +Expected: FAIL because `NavMenu.razor` still contains `v3.5.1`. + +- [ ] **Step 3: Synchronize version `3.6.0`** + +Update: + +- `Directory.Build.props`: `3.6.0` +- `compose.yaml`: `gmrelay-bot`, `gmrelay-discord-bot`, and `gmrelay-web` image tags +- `.gitea/workflows/deploy.yml`: `VERSION: 3.6.0` +- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v3.6.0` +- `README.md`: current version `v3.6.0` + +- [ ] **Step 4: Update user-facing documentation** + +In `README.md` document: + +- completed adventure portfolios; +- `/portfolio/{slug}`; +- participant-submitted moderated reviews; +- cover uploads stored in `portfolio_covers`; +- optional `PORTFOLIO_COVERS_VOLUME_NAME`. + +In `docs/c4-system-context.md` document: + +- public portfolio pages and player review submission; +- portfolio tables in PostgreSQL; +- `PortfolioService`, `AuthorizedPortfolioService`, and `IPortfolioCoverStorage`; +- persistent `portfolio_covers` volume and future S3 replacement boundary. + +- [ ] **Step 5: Run version test to verify GREEN** + +Run the Task 8 version-test command again. Expected: PASS. + +- [ ] **Step 6: Commit** + +```powershell +git add README.md docs/c4-system-context.md Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +git commit -m "docs: document portfolio release and bump version to 3.6.0" +``` + +--- + +### Task 9: Verify The Integrated Feature + +**Files:** +- No source changes unless verification exposes a defect. + +- [ ] **Step 1: Run the full test suite** + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run the full build** + +```powershell +dotnet build +``` + +Expected: build succeeds with zero warnings and zero errors. + +- [ ] **Step 3: Run formatting verification** + +```powershell +dotnet format --verify-no-changes --verbosity diagnostic +``` + +Expected: exit code `0`. + +- [ ] **Step 4: Check version synchronization** + +```powershell +rg -n "3\.5\.1|3\.6\.0" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md +``` + +Expected: release references use `3.6.0`; no required release file contains `3.5.1`. + +- [ ] **Step 5: Start the local app and visually inspect with Browser** + +Run: + +```powershell +dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj +``` + +Use the in-app Browser plugin to inspect: + +- public GM profile portfolio cards; +- public club portfolio cards; +- `/portfolio/{slug}` detail page; +- eligible review form and consent checkbox; +- protected editor layout; +- mobile-width responsive layout. + +- [ ] **Step 6: Request code review** + +Dispatch a review subagent focused on: + +- privacy of public DTOs and Razor output; +- SQL authorization and cross-club boundaries; +- cover-storage path safety and cleanup; +- review eligibility and moderation; +- unchanged `/showcase` future-session behavior; +- version synchronization. + +- [ ] **Step 7: Apply review fixes and repeat verification** + +Repeat Steps 1-4 after any change. + +--- + +## Execution Order And Ownership + +Execute tasks sequentially because later tasks depend on earlier contracts: + +1. Schema +2. Contracts and validation +3. Cover storage +4. Portfolio persistence +5. Authorized orchestration +6. Protected UI +7. Public UI +8. Documentation and version +9. Integrated verification + +For subagent execution, assign one fresh worker per task. Workers must not revert edits from earlier tasks. Use separate spec-compliance and code-quality review agents after each task as required by `superpowers:subagent-driven-development`. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md new file mode 100644 index 0000000..e062d9f --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -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 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 Masters, + IReadOnlyList 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. diff --git a/src/GmRelay.AppHost/Program.cs b/src/GmRelay.AppHost/Program.cs index 6578e71..562d304 100644 --- a/src/GmRelay.AppHost/Program.cs +++ b/src/GmRelay.AppHost/Program.cs @@ -2,18 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args); var postgres = builder.AddPostgres("postgres") .WithPgAdmin() - .AddDatabase("gmrelay-db"); + .AddDatabase("gmrelaydb"); -builder.AddProject("bot") +var bot = builder.AddProject("bot") .WithReference(postgres) - .WaitFor(postgres); + .WaitFor(postgres) + .WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false) + .WithHttpHealthCheck("/health", endpointName: "health"); builder.AddProject("discord") .WithReference(postgres) - .WaitFor(postgres); + .WaitFor(postgres) + .WaitFor(bot); builder.AddProject("web") .WithReference(postgres) - .WaitFor(postgres); + .WaitFor(postgres) + .WaitFor(bot); builder.Build().Run(); diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql new file mode 100644 index 0000000..bd34c44 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -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'; diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs index 5a83a4c..046a1b7 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -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( + """ + 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 diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs index 94e0bdb..fd69fc5 100644 --- a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -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( """ 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 diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 6383108..6f1ba21 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -73,7 +73,7 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor b/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor new file mode 100644 index 0000000..524d1d6 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor @@ -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 + +Проведённые сессии — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (sessions is null) + { +
+
+
+
+
+ } + else if (sessions.Count == 0) + { +
+
+
📭
+
Проведённых сессий пока нет
+

Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.

+
+
+ } + else + { +
+ @foreach (var session in sessions) + { +
+
+ @session.Title + @session.ScheduledAt.FormatMoscow() +
+
+ +
+
+ } +
+ } +
+ +@code { + [Parameter] public Guid GroupId { get; set; } + + private IReadOnlyList? 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; + } + } +} diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 371c8cc..fa9c46a 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -1,10 +1,12 @@ @page "/group/{GroupId:guid}" @using GmRelay.Web.Services @using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] @inject AuthorizedSessionService SessionService +@inject AuthorizedPortfolioService PortfolioService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -138,6 +140,60 @@ } + @if (portfolioGames is not null) + { +
+
+
+

Проведённые приключения

+

Черновики и опубликованные приключения для каталога мастера.

+
+ +
+ + @if (portfolioGames.Count == 0) + { +
+
Приключений пока нет
+

Создайте первый черновик и добавьте проведённые сессии.

+
+ } + else + { +
+ @foreach (var game in portfolioGames) + { +
+
+ @game.Title + + @(game.IsPublic ? "Опубликовано" : "Черновик") + +
+
+ @game.SessionCount игр + @game.MasterCount мастеров + @if (game.PendingReviewCount > 0) + { + @game.PendingReviewCount на модерации + } +
+ +
+ } +
+ } + + +
+ } + @if (campaignTemplates is not null) {
@@ -481,6 +537,7 @@ private List? campaignTemplates; private WebGroupManagement? groupManagement; private WebPublicGroupSettings? publicSettings; + private IReadOnlyList? portfolioGames; private List batchModels = []; private List campaignTemplateModels = []; private Guid? promotingSessionId; @@ -490,6 +547,7 @@ private Guid? publishingSessionId; private string? removingCoGmId; private bool isAddingCoGm; + private bool isCreatingDraft; private bool savingPublicSettings; private string? currentPlatform; private string? externalUserId; @@ -545,11 +603,38 @@ return; } + portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId); + RebuildBatchModels(); RebuildCampaignTemplateModels(); RebuildPublicSettingsModel(); } + private async Task CreateDraft() + { + errorMessage = null; + successMessage = null; + isCreatingDraft = true; + + try + { + var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, null); + Navigation.NavigateTo($"/portfolio/manage/{portfolioId}"); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось создать черновик: " + ex.Message; + } + finally + { + isCreatingDraft = false; + } + } + private async Task SavePublicSettings() { errorMessage = null; diff --git a/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor b/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor new file mode 100644 index 0000000..582961b --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor @@ -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 + +Портфолио — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ ✅ @successMessage +
+ } + + @if (editor is null) + { +
+
+
+
+
+ } + else + { +
+
+
+

Параметры публикации

+

Управление видимостью и обложкой приключения.

+
+ + @(editor.IsPublic ? "Опубликовано" : "Черновик") + +
+ +
+
+ @if (!string.IsNullOrEmpty(editor.CoverPath)) + { + Обложка + } + else + { +
Обложка не загружена
+ } + + +
+ +
+ +
+ + +
+
+ + +
Латиница, цифры и дефисы, например "night-city-run".
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
+
+
+ +
+
+
+

Проведённые сессии

+

Отметьте игры, которые вошли в это приключение.

+
+ @editorModel.SessionIds.Count +
+
+ @foreach (var session in editor.Sessions) + { + + } +
+
+ +
+
+
+

Мастера приключения

+

Выберите мастеров, которые вели это приключение.

+
+ @editorModel.MasterPlayerIds.Count +
+
+ @foreach (var master in editor.Masters) + { + + } +
+
+ +
+
+
+

Модерация отзывов

+

Одобрите, отклоните или скройте отзывы игроков перед публикацией.

+
+ + @editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации + +
+ + @if (editor.Reviews.Count == 0) + { +
+
Отзывов пока нет
+

Игроки смогут оставить отзыв после публикации приключения.

+
+ } + else + { +
+ @foreach (var review in editor.Reviews) + { +
+
+ @review.AuthorDisplayName + @TranslateReviewStatus(review.ModerationStatus) + @review.CreatedAt.ToString("dd.MM.yyyy HH:mm") +
+

@review.Body

+
+ + + +
+
+ } +
+ } +
+ } +
+ +@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 SessionIds { get; set; } = new(); + public List MasterPlayerIds { get; set; } = new(); + } +} diff --git a/src/GmRelay.Web/Components/Pages/PublicClub.razor b/src/GmRelay.Web/Components/Pages/PublicClub.razor index ce511cb..e8c6b25 100644 --- a/src/GmRelay.Web/Components/Pages/PublicClub.razor +++ b/src/GmRelay.Web/Components/Pages/PublicClub.razor @@ -1,7 +1,10 @@ @page "/club/{Slug}" @layout PublicLayout @inject ISessionStore SessionStore +@inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@using GmRelay.Web.Components.Portfolio +@using GmRelay.Web.Services.Portfolio @PageTitleText @@ -75,12 +78,22 @@ else if (club is not null) }
} + + @if (portfolioGames.Count > 0) + { +
+

Завершённые игры клуба

+

Публичные портфолио, опубликованные мастерами этого клуба.

+ +
+ } } @code { [Parameter] public string? Slug { get; set; } private WebPublicClub? club; + private IReadOnlyList portfolioGames = []; private bool loaded; private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay"; @@ -93,9 +106,13 @@ else if (club is not null) protected override async Task OnParametersSetAsync() { loaded = false; - club = string.IsNullOrWhiteSpace(Slug) + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + club = trimmedSlug is null ? null - : await SessionStore.GetPublicClubBySlugAsync(Slug.Trim()); + : await SessionStore.GetPublicClubBySlugAsync(trimmedSlug); + portfolioGames = trimmedSlug is null + ? [] + : await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug); loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor index e8ada8a..fd40887 100644 --- a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor +++ b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor @@ -1,7 +1,10 @@ @page "/gm/{Slug}" @layout PublicLayout @inject ISessionStore SessionStore +@inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@using GmRelay.Web.Components.Portfolio +@using GmRelay.Web.Services.Portfolio @PageTitleText @@ -83,12 +86,22 @@ else if (profile is not null) } } + + @if (portfolioGames.Count > 0) + { +
+

Портфолио

+

Завершённые игры мастера, открытые для публичного просмотра.

+ +
+ } } @code { [Parameter] public string? Slug { get; set; } private GmRelay.Web.Services.PublicMasterProfile? profile; + private IReadOnlyList portfolioGames = []; private bool loaded; private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay"; @@ -101,9 +114,13 @@ 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(); + profile = trimmedSlug is null ? null - : await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim()); + : await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug); + portfolioGames = trimmedSlug is null + ? [] + : await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug); loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor b/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor new file mode 100644 index 0000000..eda2deb --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor @@ -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 + +@PageTitleText + +@if (loaded && game is null) +{ + + + + +
+ Недоступно +

Портфолио не найдено

+

Эта игра скрыта, ещё не опубликована или короткий адрес больше не используется.

+
+} +else if (!loaded) +{ +
+
+
+
+} +else if (game is not null) +{ + + + + + @if (!string.IsNullOrWhiteSpace(game.CoverPath)) + { +
+ } + +
+ Завершено +

@game.Title

+

Завершено @game.CompletedAt.ToLocalTime().FormatMoscow()

+
+ @if (!string.IsNullOrWhiteSpace(game.System)) + { + @GetSystemDisplayName(game.System) + } + @if (!string.IsNullOrWhiteSpace(game.Format)) + { + @TranslateFormat(game.Format) + } +
+
+ +
+ @if (!string.IsNullOrWhiteSpace(game.Description)) + { +
+

Описание

+

@game.Description

+
+ } + + @if (game.Masters.Count > 0) + { + + } + + @if (!string.IsNullOrWhiteSpace(game.ClubSlug) && !string.IsNullOrWhiteSpace(game.ClubName)) + { + + } + + +
+ +
+

Отзывы игроков

+ @if (game.Reviews.Count == 0) + { +

Пока нет одобренных отзывов.

+ } + else + { +
    + @foreach (var review in game.Reviews) + { +
  • +
    + @review.AuthorDisplayName + @review.CreatedAt.ToLocalTime().FormatMoscowShort() +
    +

    @review.Body

    +
  • + } +
+ } +
+ +
+

Оставить отзыв

+ @switch (submissionState) + { + case PortfolioReviewSubmissionState.RequiresAuthentication: +

Войдите, чтобы оставить отзыв об этом приключении.

+ + break; + case PortfolioReviewSubmissionState.Ineligible: +

Отзыв могут оставить только игроки, участвовавшие в этом приключении.

+ break; + case PortfolioReviewSubmissionState.AlreadySubmitted: +

Отзыв отправлен на модерацию.

+ break; + case PortfolioReviewSubmissionState.Eligible: + +
+ + + @if (!string.IsNullOrWhiteSpace(submissionError)) + { +

@submissionError

+ } +
+ +
+
+
+ break; + } +
+} + +@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(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; } + } +} diff --git a/src/GmRelay.Web/Components/Pages/SessionHistory.razor b/src/GmRelay.Web/Components/Pages/SessionHistory.razor index 623c133..2cb42e7 100644 --- a/src/GmRelay.Web/Components/Pages/SessionHistory.razor +++ b/src/GmRelay.Web/Components/Pages/SessionHistory.razor @@ -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 @@ {

@sessionTitle

} + @if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow) + { +
+ +
+ } @if (entries is null) @@ -78,6 +88,8 @@ private List? 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" => "Название", diff --git a/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor b/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor new file mode 100644 index 0000000..dc0926f --- /dev/null +++ b/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor @@ -0,0 +1,64 @@ +@using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio + + + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Games { get; set; } = []; + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; +} diff --git a/src/GmRelay.Web/Dockerfile b/src/GmRelay.Web/Dockerfile index 970cd35..9394d78 100644 --- a/src/GmRelay.Web/Dockerfile +++ b/src/GmRelay.Web/Dockerfile @@ -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 diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index f4308b0..66318ea 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -2,6 +2,8 @@ using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio; +using GmRelay.Web.Services.Portfolio.Covers; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; @@ -37,12 +39,15 @@ builder.AddNpgsqlDataSource("gmrelaydb"); // Add Services builder.Services.AddSingleton(); +builder.Services.AddPortfolioCoverStorage(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("Discord")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add Bot Client builder.Services.AddSingleton(sp => @@ -94,6 +99,8 @@ app.Use(async (context, next) => await next(); }); +app.UsePortfolioCoverFiles(); + app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); diff --git a/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs b/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs new file mode 100644 index 0000000..ef81884 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs @@ -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, ""); + } + + 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, ""); + } + + 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> 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 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> 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 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 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, ""); + } + + 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 + }; + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs b/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs new file mode 100644 index 0000000..bea14c0 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs @@ -0,0 +1,15 @@ +namespace GmRelay.Web.Services.Portfolio.Covers; + +public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType); + +public interface IPortfolioCoverStorage +{ + Task SaveAsync( + Stream content, + string contentType, + CancellationToken cancellationToken = default); + + Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default); + + string GetPublicPath(string storageKey); +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs b/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs new file mode 100644 index 0000000..1a95d36 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs @@ -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 _logger; + + public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options) + : this(options, logger: null) + { + } + + public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger? logger) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.StoragePath)) + { + throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured."); + } + + _storagePath = options.StoragePath; + _logger = logger ?? NullLogger.Instance; + } + + public async Task 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); + } + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs new file mode 100644 index 0000000..a99c5a3 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs @@ -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() + .Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName)) + .Validate( + o => !string.IsNullOrWhiteSpace(o.StoragePath), + "PortfolioCovers:StoragePath must be configured.") + .ValidateOnStart(); + + services.AddSingleton(sp => + { + var options = sp.GetRequiredService< + Microsoft.Extensions.Options.IOptions>().Value; + var logger = sp.GetService()?.CreateLogger() + ?? NullLogger.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>().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; + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs new file mode 100644 index 0000000..79979a5 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs @@ -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; +} diff --git a/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs b/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs new file mode 100644 index 0000000..91f523a --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs @@ -0,0 +1,36 @@ +namespace GmRelay.Web.Services.Portfolio; + +public interface IPortfolioStore +{ + Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug); + + Task> GetPublicPortfolioGamesForClubAsync(string clubSlug); + + Task GetPublicPortfolioGameBySlugAsync(string slug); + + Task> GetPortfolioGamesForGroupAsync(Guid groupId); + + Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId); + + Task GetPortfolioGameForManagementAsync(Guid portfolioGameId); + + Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId); + + Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId); + + Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId); + + Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update); + + Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey); + + Task 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 GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId); + + Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body); +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs new file mode 100644 index 0000000..60424a2 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs @@ -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 Masters, + IReadOnlyList 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 Sessions, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PortfolioGameUpdate( + string Title, + string? PublicSlug, + string? Description, + string? System, + string? Format, + IReadOnlyList SessionIds, + IReadOnlyList MasterPlayerIds); + +public enum PortfolioReviewSubmissionState +{ + RequiresAuthentication, + Ineligible, + Eligible, + AlreadySubmitted +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs new file mode 100644 index 0000000..569d97a --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs @@ -0,0 +1,1109 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio.Covers; +using Npgsql; +using System.Data; + +namespace GmRelay.Web.Services.Portfolio; + +public sealed class PortfolioService( + NpgsqlDataSource dataSource, + IPortfolioCoverStorage coverStorage) : IPortfolioStore +{ + // --- Public reads --- + + public async Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt + FROM portfolio_games pg + JOIN portfolio_game_masters pgm ON pgm.portfolio_game_id = pg.id + JOIN players p ON p.id = pgm.player_id + JOIN master_profiles mp ON mp.player_id = p.id + WHERE pg.is_public = true + AND mp.is_public = true + AND mp.public_slug IS NOT NULL + AND lower(mp.public_slug) = lower(@MasterSlug) + ORDER BY pg.completed_at DESC + """, + new { MasterSlug = masterSlug }); + + return rows.Select(MapToPublicCard).ToList(); + } + + public async Task> GetPublicPortfolioGamesForClubAsync(string clubSlug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt + FROM portfolio_games pg + JOIN game_groups g ON g.id = pg.group_id + WHERE pg.is_public = true + AND g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND lower(g.public_slug) = lower(@ClubSlug) + ORDER BY pg.completed_at DESC + """, + new { ClubSlug = clubSlug }); + + return rows.Select(MapToPublicCard).ToList(); + } + + public async Task GetPublicPortfolioGameBySlugAsync(string slug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var detail = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + COALESCE(NULLIF(g.name, g.external_group_id), NULL) AS ClubName, + CASE + WHEN g.public_schedule_enabled = true AND g.public_slug IS NOT NULL + THEN g.public_slug + ELSE NULL + END AS ClubSlug + FROM portfolio_games pg + JOIN game_groups g ON g.id = pg.group_id + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (detail is null) + { + return null; + } + + var masters = (await conn.QueryAsync( + """ + SELECT mp.public_slug AS Slug, + mp.display_name AS DisplayName + FROM portfolio_game_masters pgm + JOIN master_profiles mp ON mp.player_id = pgm.player_id + WHERE pgm.portfolio_game_id = @PortfolioGameId + AND mp.is_public = true + AND mp.public_slug IS NOT NULL + ORDER BY mp.display_name + """, + new { PortfolioGameId = detail.Id })).ToList(); + + var reviews = (await conn.QueryAsync( + """ + SELECT r.author_display_name AS AuthorDisplayName, + r.body AS Body, + r.created_at AS CreatedAt + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.moderation_status = 'Approved' + AND r.publication_consent_at IS NOT NULL + ORDER BY r.created_at DESC + """, + new { PortfolioGameId = detail.Id })).ToList(); + + return new PublicPortfolioGame( + detail.Slug!, + detail.Title, + detail.Description ?? string.Empty, + string.IsNullOrEmpty(detail.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(detail.CoverStorageKey), + detail.System, + detail.Format, + detail.CompletedAt, + detail.ClubSlug is null ? null : detail.ClubName, + detail.ClubSlug, + masters.Select(m => new PublicPortfolioMaster(m.Slug!, m.DisplayName)).ToList(), + reviews.Select(r => new PublicPortfolioReview(r.AuthorDisplayName, r.Body, r.CreatedAt)).ToList()); + } + + // --- Protected reads --- + + public async Task> GetPortfolioGamesForGroupAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.is_public AS IsPublic, + pg.completed_at AS CompletedAt, + COALESCE(session_counts.count, 0)::int AS SessionCount, + COALESCE(master_counts.count, 0)::int AS MasterCount, + COALESCE(pending_counts.count, 0)::int AS PendingReviewCount + FROM portfolio_games pg + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = pg.id + ) session_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = pg.id + ) master_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = pg.id + AND r.moderation_status = 'Pending' + ) pending_counts ON true + WHERE pg.group_id = @GroupId + ORDER BY pg.completed_at DESC, pg.created_at DESC + """, + new { GroupId = groupId }); + + return rows.Select(r => new PortfolioGameSummary( + r.Id, + r.GroupId, + r.Title, + r.PublicSlug, + r.IsPublic, + r.CompletedAt, + r.SessionCount, + r.MasterCount, + r.PendingReviewCount)).ToList(); + } + + public async Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT group_id FROM portfolio_games WHERE id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }); + } + + public async Task GetPortfolioGameForManagementAsync(Guid portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var header = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }); + + if (header is null) + { + return null; + } + + var sessions = (await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND pgs.session_id = s.id + ) AS Selected + FROM sessions s + WHERE s.group_id = @GroupId + AND s.scheduled_at < now() + ORDER BY s.scheduled_at DESC + """, + new { PortfolioGameId = header.Id, GroupId = header.GroupId })).ToList(); + + var masters = (await conn.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = @PortfolioGameId + AND pgm.player_id = p.id + ) AS Selected + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + p.display_name + """, + new + { + PortfolioGameId = header.Id, + GroupId = header.GroupId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue + })).ToList(); + + var reviews = (await conn.QueryAsync( + """ + SELECT r.id AS Id, + r.author_display_name AS AuthorDisplayName, + r.body AS Body, + r.moderation_status AS ModerationStatus, + r.created_at AS CreatedAt + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + ORDER BY r.created_at DESC + """, + new { PortfolioGameId = header.Id })).ToList(); + + return new PortfolioGameEditor( + header.Id, + header.GroupId, + header.Title, + header.PublicSlug, + header.Description, + header.CoverStorageKey is null ? null : coverStorage.GetPublicPath(header.CoverStorageKey), + header.System, + header.Format, + header.CompletedAt, + header.IsPublic, + sessions.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(), + masters.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(), + reviews.Select(r => new PortfolioReviewForModeration(r.Id, r.AuthorDisplayName, r.Body, r.ModerationStatus, r.CreatedAt)).ToList()); + } + + public async Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + COALESCE(linked.Selected, false) AS Selected + FROM sessions s + LEFT JOIN LATERAL ( + SELECT true AS Selected + FROM portfolio_game_sessions pgs + WHERE pgs.session_id = s.id + AND (@PortfolioGameId IS NULL OR pgs.portfolio_game_id = @PortfolioGameId) + ) linked ON true + WHERE s.group_id = @GroupId + AND s.scheduled_at < now() + ORDER BY s.scheduled_at DESC + """, + new { GroupId = groupId, PortfolioGameId = portfolioGameId }); + + return rows.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(); + } + + public async Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + COALESCE(linked.Selected, false) AS Selected + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + LEFT JOIN LATERAL ( + SELECT true AS Selected + FROM portfolio_game_masters pgm + WHERE pgm.player_id = p.id + AND (@PortfolioGameId IS NULL OR pgm.portfolio_game_id = @PortfolioGameId) + ) linked ON true + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + p.display_name + """, + new + { + GroupId = groupId, + PortfolioGameId = portfolioGameId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue + }); + + return rows.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(); + } + + // --- Protected writes --- + + public async Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var newId = await conn.ExecuteScalarAsync( + """ + INSERT INTO portfolio_games (group_id, title) + VALUES (@GroupId, 'New adventure') + RETURNING id + """, + new { GroupId = groupId }, + transaction); + + if (preselectedSessionId is not null) + { + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + SELECT @PortfolioGameId, s.id + FROM sessions s + WHERE s.id = @SessionId + AND s.group_id = @GroupId + AND s.scheduled_at < now() + AND NOT EXISTS ( + SELECT 1 FROM portfolio_game_sessions pgs WHERE pgs.session_id = s.id + ) + """, + new + { + PortfolioGameId = newId, + SessionId = preselectedSessionId.Value, + GroupId = groupId + }, + transaction); + } + + await transaction.CommitAsync(); + return newId; + } + + public async Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, 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 system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim(); + var format = PortfolioValidation.NormalizeFormat(update.Format); + + var sessionIds = update.SessionIds?.Distinct().ToArray() ?? Array.Empty(); + var masterPlayerIds = update.MasterPlayerIds?.Distinct().ToArray() ?? Array.Empty(); + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var existing = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (existing is null) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + + try + { + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET title = @Title, + public_slug = @PublicSlug, + description = @Description, + system = @System, + format = @Format, + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new + { + PortfolioGameId = portfolioGameId, + Title = title, + PublicSlug = slug, + Description = description, + System = system, + Format = format + }, + transaction); + } + catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation) + { + throw new InvalidOperationException("Public slug is already in use.", ex); + } + + // Unpublish before replacing required child links so the deferred validator never sees a + // public card without a session/master. The trigger acquires the same advisory lock. + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + WHERE pg.id = @PortfolioGameId + AND pg.is_public = true + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + if (sessionIds.Length > 0) + { + var validatedSessions = (await conn.QueryAsync( + """ + SELECT s.id + FROM sessions s + WHERE s.id = ANY(@SessionIds) + AND s.group_id = @GroupId + AND s.scheduled_at < now() + """, + new { SessionIds = sessionIds, GroupId = groupId }, + transaction)).ToHashSet(); + + if (validatedSessions.Count != sessionIds.Length) + { + throw new InvalidOperationException("All linked sessions must belong to the same group and be in the past."); + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + SELECT @PortfolioGameId, UNNEST(@SessionIds::uuid[]) + """, + new { PortfolioGameId = portfolioGameId, SessionIds = sessionIds }, + transaction); + } + else + { + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + } + + if (masterPlayerIds.Length > 0) + { + var validatedMasters = (await conn.QueryAsync( + """ + SELECT p.id + FROM players p + JOIN group_managers gm ON gm.player_id = p.id + WHERE p.id = ANY(@PlayerIds) + AND gm.group_id = @GroupId + """, + new { PlayerIds = masterPlayerIds, GroupId = groupId }, + transaction)).ToHashSet(); + + if (validatedMasters.Count != masterPlayerIds.Length) + { + throw new InvalidOperationException("All masters must be managers of the same group."); + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) + SELECT @PortfolioGameId, UNNEST(@PlayerIds::uuid[]) + """, + new { PortfolioGameId = portfolioGameId, PlayerIds = masterPlayerIds }, + transaction); + } + else + { + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + } + + await transaction.CommitAsync(); + } + + public async Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey) + { + if (string.IsNullOrWhiteSpace(storageKey)) + { + throw new InvalidOperationException("Cover storage key must not be empty."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var priorKey = await conn.QuerySingleOrDefaultAsync( + """ + SELECT cover_storage_key + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (priorKey is null) + { + // Could be NULL cover key on the row. Distinguish "missing" from "null cover". + var rowExists = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId + ) + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + if (!rowExists) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + } + + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET cover_storage_key = @StorageKey, + updated_at = now() + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId, StorageKey = storageKey }, + transaction); + + await transaction.CommitAsync(); + return priorKey; + } + + public async Task DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var coverKey = await conn.QuerySingleOrDefaultAsync( + """ + SELECT cover_storage_key + FROM portfolio_games + WHERE id = @PortfolioGameId + AND group_id = @GroupId + FOR UPDATE + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (coverKey is null) + { + // Could be NULL cover key on the row. Distinguish "missing" from "null cover". + var rowExists = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId + ) + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + if (!rowExists) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId", + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + await transaction.CommitAsync(); + return coverKey; + } + + public async Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var row = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (row is null) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + + if (!isPublic) + { + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + await transaction.CommitAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(row.PublicSlug)) + { + throw new InvalidOperationException("Public slug is required before publishing."); + } + if (string.IsNullOrWhiteSpace(row.Description)) + { + throw new InvalidOperationException("Description is required before publishing."); + } + if (string.IsNullOrWhiteSpace(row.CoverStorageKey)) + { + throw new InvalidOperationException("Cover image is required before publishing."); + } + + var sessionCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + if (sessionCount == 0) + { + throw new InvalidOperationException("At least one linked session is required before publishing."); + } + + var futureSessionCount = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM portfolio_game_sessions pgs + JOIN sessions s ON s.id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND s.scheduled_at < now() + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + if (futureSessionCount != sessionCount) + { + throw new InvalidOperationException("Every linked session must already be in the past before publishing."); + } + + var masterCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + if (masterCount == 0) + { + throw new InvalidOperationException("At least one master is required before publishing."); + } + + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = true, + published_at = COALESCE(pg.published_at, now()), + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + await transaction.CommitAsync(); + } + + public async Task ModeratePortfolioReviewAsync( + Guid reviewId, + Guid portfolioGameId, + Guid groupId, + Guid moderatorPlayerId, + string moderationStatus) + { + if (moderationStatus is not "Approved" and not "Rejected" and not "Hidden") + { + throw new InvalidOperationException("Moderation status must be Approved, Rejected, or Hidden."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var updated = await conn.ExecuteAsync( + """ + UPDATE portfolio_game_reviews r + SET moderation_status = @ModerationStatus, + moderated_by_player_id = @ModeratorPlayerId, + moderated_at = now(), + updated_at = now() + FROM portfolio_games pg + WHERE r.id = @ReviewId + AND r.portfolio_game_id = pg.id + AND pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + """, + new + { + ReviewId = reviewId, + PortfolioGameId = portfolioGameId, + GroupId = groupId, + ModeratorPlayerId = moderatorPlayerId, + ModerationStatus = moderationStatus + }, + transaction); + + if (updated == 0) + { + throw new InvalidOperationException("Review not found in the specified portfolio game."); + } + + await transaction.CommitAsync(); + } + + // --- Review submission --- + + public async Task GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) + { + return PortfolioReviewSubmissionState.RequiresAuthentication; + } + + var portfolioGameId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id + FROM portfolio_games pg + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (portfolioGameId is null) + { + return PortfolioReviewSubmissionState.Ineligible; + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.author_player_id = ANY(@PlayerIds) + ) + """, + new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds }); + if (existing) + { + return PortfolioReviewSubmissionState.AlreadySubmitted; + } + + var eligible = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + JOIN session_participants sp ON sp.session_id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND sp.player_id = ANY(@PlayerIds) + AND sp.is_gm = false + AND sp.registration_status = @Active + AND EXISTS ( + SELECT 1 FROM sessions s + WHERE s.id = pgs.session_id + AND s.scheduled_at < now() + ) + ) + """, + new + { + PortfolioGameId = portfolioGameId.Value, + PlayerIds = playerIds, + Active = ParticipantRegistrationStatus.Active + }); + + return eligible + ? PortfolioReviewSubmissionState.Eligible + : PortfolioReviewSubmissionState.Ineligible; + } + + public async Task SubmitPortfolioReviewAsync( + string slug, + string platform, + string externalUserId, + string displayName, + string body) + { + var normalizedBody = PortfolioValidation.NormalizeReviewBody(body); + var normalizedName = (displayName ?? string.Empty).Trim(); + if (normalizedName.Length == 0) + { + throw new InvalidOperationException("Display name is required."); + } + if (normalizedName.Length > 255) + { + throw new InvalidOperationException("Display name is too long."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + var portfolioGameId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id + FROM portfolio_games pg + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (portfolioGameId is null) + { + throw new InvalidOperationException("Public portfolio game not found."); + } + + var eligible = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + JOIN session_participants sp ON sp.session_id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND sp.player_id = ANY(@PlayerIds) + AND sp.is_gm = false + AND sp.registration_status = @Active + AND EXISTS ( + SELECT 1 FROM sessions s + WHERE s.id = pgs.session_id + AND s.scheduled_at < now() + ) + ) + """, + new + { + PortfolioGameId = portfolioGameId.Value, + PlayerIds = playerIds, + Active = ParticipantRegistrationStatus.Active + }); + + if (!eligible) + { + throw new InvalidOperationException("Only past participants of a linked session can submit a review."); + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.author_player_id = ANY(@PlayerIds) + ) + """, + new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds }); + if (existing) + { + throw new InvalidOperationException("You have already submitted a review for this adventure."); + } + + var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + await using var transaction = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_reviews + (portfolio_game_id, author_player_id, author_display_name, body, publication_consent_at, moderation_status) + VALUES + (@PortfolioGameId, @AuthorPlayerId, @AuthorDisplayName, @Body, now(), 'Pending') + ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING + """, + new + { + PortfolioGameId = portfolioGameId.Value, + AuthorPlayerId = effectiveId.Value, + AuthorDisplayName = normalizedName, + Body = normalizedBody + }, + transaction); + + await transaction.CommitAsync(); + } + + // --- Internal helpers --- + + private static async Task ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + return await conn.QuerySingleOrDefaultAsync( + "SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId", + new { Platform = platform, ExternalUserId = externalUserId }); + } + + private static async Task ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var playerId = await ResolvePlayerIdAsync(conn, platform, externalUserId); + if (playerId is null) + { + return null; + } + + var primaryId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT primary_player_id + FROM player_links + WHERE secondary_player_id = @PlayerId + """, + new { PlayerId = playerId.Value }); + + return primaryId ?? playerId; + } + + private static async Task ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + { + return []; + } + + return (await conn.QueryAsync( + """ + SELECT @EffectiveId + UNION + SELECT secondary_player_id + FROM player_links + WHERE primary_player_id = @EffectiveId + """, + new { EffectiveId = effectiveId.Value })).ToArray(); + } + + private PublicPortfolioCard MapToPublicCard(PublicCardRow row) + { + return new PublicPortfolioCard( + row.Slug ?? string.Empty, + row.Title, + string.IsNullOrEmpty(row.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(row.CoverStorageKey), + row.System, + row.Format, + row.CompletedAt); + } + + // --- Internal DTOs (Dapper row shapes) --- + + private sealed record PublicCardRow( + Guid Id, + string? Slug, + string Title, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt); + + private sealed record PublicDetailRow( + Guid Id, + string? Slug, + string Title, + string? Description, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt, + string? ClubName, + string? ClubSlug); + + private sealed record PublicMasterRow(string? Slug, string DisplayName); + + private sealed record PublicReviewRow(string AuthorDisplayName, string Body, DateTime CreatedAt); + + private sealed record PortfolioGameSummaryRow( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + bool IsPublic, + DateTime CompletedAt, + int SessionCount, + int MasterCount, + int PendingReviewCount); + + private sealed record EditorHeaderRow( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + string? Description, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt, + bool IsPublic); + + private sealed record SessionOptionRow(Guid Id, string Title, DateTime ScheduledAt, bool Selected); + + private sealed record MasterOptionRow(Guid PlayerId, string DisplayName, bool Selected); + + private sealed record ModerationReviewRow( + Guid Id, + string AuthorDisplayName, + string Body, + string ModerationStatus, + DateTime CreatedAt); +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs new file mode 100644 index 0000000..c15c59f --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs @@ -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 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'); +} diff --git a/src/GmRelay.Web/appsettings.Development.json b/src/GmRelay.Web/appsettings.Development.json index 0c208ae..25f234b 100644 --- a/src/GmRelay.Web/appsettings.Development.json +++ b/src/GmRelay.Web/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "PortfolioCovers": { + "StoragePath": "../../artifacts/portfolio-covers" } } diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index a769634..aa74d10 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -2021,3 +2021,427 @@ body.telegram-mini-app .session-card-mobile { object-fit: cover; border-radius: 50%; } + +/* === Portfolio Management === */ +.portfolio-management-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.portfolio-management-row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.6fr) auto; + gap: 1rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + align-items: center; +} + +.portfolio-management-info { + display: flex; + flex-direction: column; + gap: 0.375rem; + min-width: 0; +} + +.portfolio-management-title { + color: var(--text-primary); + font-weight: 500; + text-decoration: none; + word-break: break-word; +} + +.portfolio-management-title:hover { + color: var(--accent-primary); +} + +.portfolio-management-meta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + align-items: center; +} + +.portfolio-management-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.portfolio-editor-grid { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr); + gap: 1.5rem; + margin-top: 0.5rem; +} + +.portfolio-editor-cover { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: stretch; +} + +.portfolio-editor-cover-image { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.portfolio-editor-cover-empty { + width: 100%; + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background: var(--bg-surface); + border: 1px dashed var(--border-color); + border-radius: var(--radius-md); +} + +.portfolio-editor-cover-input { + display: none; +} + +.portfolio-editor-fields { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.portfolio-editor-publish-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.portfolio-option-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-top: 0.5rem; +} + +.portfolio-option-row { + display: grid; + grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); +} + +.portfolio-option-title { + color: var(--text-primary); + font-weight: 500; + word-break: break-word; +} + +.portfolio-option-meta { + color: var(--text-muted); + font-size: 0.875rem; + text-align: right; +} + +.portfolio-review-moderation { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.portfolio-review-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.portfolio-review-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.portfolio-review-author { + color: var(--text-primary); + font-weight: 500; +} + +.portfolio-review-date { + color: var(--text-muted); + font-size: 0.8125rem; + margin-left: auto; +} + +.portfolio-review-body { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; + white-space: pre-wrap; +} + +.portfolio-review-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.portfolio-completed-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.portfolio-completed-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + align-items: center; +} + +.portfolio-completed-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.portfolio-completed-title { + color: var(--text-primary); + font-weight: 500; + text-decoration: none; + word-break: break-word; +} + +.portfolio-completed-title:hover { + color: var(--accent-primary); +} + +.portfolio-completed-date { + color: var(--text-muted); + font-size: 0.875rem; +} + +.portfolio-completed-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +@media (max-width: 768px) { + .portfolio-editor-grid { + grid-template-columns: 1fr; + } + + .portfolio-management-row { + grid-template-columns: 1fr; + } + + .portfolio-management-actions, + .portfolio-completed-actions { + justify-content: flex-start; + } + + .portfolio-option-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .portfolio-option-meta { + grid-column: 1 / -1; + text-align: left; + } + + .portfolio-completed-row { + grid-template-columns: 1fr; + } +} + +/* === Public Portfolio === */ +.portfolio-section { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-section h2 { + font-size: 1.25rem; + margin: 0; +} + +.portfolio-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + margin-top: 0.25rem; +} + +.portfolio-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + color: inherit; + text-decoration: none; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.portfolio-card:hover { + transform: translateY(-2px); + border-color: var(--accent-primary); +} + +.portfolio-card-cover { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--bg-secondary); + background-size: cover; + background-position: center; +} + +.portfolio-card-cover-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.portfolio-card-body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem 1rem; +} + +.portfolio-card-body h3 { + margin: 0; + font-size: 1rem; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.portfolio-card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portfolio-card-date { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.portfolio-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.portfolio-cover-hero { + width: 100%; + aspect-ratio: 16 / 7; + background-color: var(--bg-secondary); + background-size: cover; + background-position: center; + border-radius: var(--radius-md); + margin-bottom: 1.5rem; + border: 1px solid var(--border-color); +} + +.portfolio-review-list { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-review-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.portfolio-review-textarea { + width: 100%; + min-height: 7rem; + resize: vertical; + padding: 0.75rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font: inherit; +} + +.portfolio-review-textarea:focus { + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + +.portfolio-review-consent { + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: var(--text-secondary); +} + +.portfolio-review-error { + margin: 0; + color: var(--status-error, #ff6b6b); + font-size: 0.875rem; +} + +@media (max-width: 768px) { + .portfolio-grid { + grid-template-columns: 1fr; + } + + .portfolio-cover-hero { + aspect-ratio: 16 / 9; + } +} + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs index 53c70fd..d72e9d5 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs @@ -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); diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj index f88c60e..215330c 100644 --- a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs new file mode 100644 index 0000000..2e43048 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs @@ -0,0 +1,857 @@ +using GmRelay.Shared.Domain; +using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio; +using GmRelay.Web.Services.Portfolio.Covers; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class AuthorizedPortfolioServiceTests +{ + private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, externalUserId), + new Claim("TelegramId", externalUserId), + new Claim("Platform", "Telegram") + }; + if (name is not null) + claims.Add(new Claim(ClaimTypes.Name, name)); + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var httpContext = new DefaultHttpContext { User = principal }; + return new HttpContextAccessor { HttpContext = httpContext }; + } + + private static IHttpContextAccessor CreateAnonymousAccessor() + { + var httpContext = new DefaultHttpContext(); + return new HttpContextAccessor { HttpContext = httpContext }; + } + + private static AuthorizedPortfolioService CreateService( + FakePortfolioStore? portfolioStore = null, + FakeSessionStore? sessionStore = null, + FakePortfolioCoverStorage? coverStorage = null, + IHttpContextAccessor? accessor = null, + bool isManager = true, + Guid? knownGroupId = null) + { + portfolioStore ??= new FakePortfolioStore(); + sessionStore ??= new FakeSessionStore(); + coverStorage ??= new FakePortfolioCoverStorage(); + accessor ??= CreateAccessor("1001"); + + // Wire a known group + manager relationship for the test + if (knownGroupId is not null) + { + portfolioStore.GroupIds[Guid.NewGuid()] = knownGroupId.Value; // placeholder + sessionStore.ManagerFlags[(knownGroupId.Value, "Telegram", "1001")] = isManager; + } + + return new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + } + + [Fact] + public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm() + { + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var draftId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + CreateDraftResult = draftId, + PortfolioGameGroupIds = new Dictionary + { + [draftId] = groupId + } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId); + + Assert.Equal(draftId, created); + Assert.Equal(groupId, portfolioStore.LastCreateGroupId); + Assert.Equal(sessionId, portfolioStore.LastCreatePreselectedSessionId); + } + + [Fact] + public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(new FakePortfolioStore(), sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.CreateDraftForCurrentUserAsync(groupId, null)); + } + + [Fact] + public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + CoverPriorKey = "old.png" + }; + var coverStorage = new FakePortfolioCoverStorage + { + SaveKey = "new.png" + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png"); + + Assert.Contains("old.png", coverStorage.DeletedKeys); + Assert.Contains("new.png", coverStorage.SavedKeys); + Assert.Equal(portfolioGameId, portfolioStore.LastSetCoverGameId); + Assert.Equal("new.png", portfolioStore.LastSetCoverKey); + } + + [Fact] + public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + SetCoverThrows = new InvalidOperationException("boom") + }; + var coverStorage = new FakePortfolioCoverStorage + { + SaveKey = "new.png" + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await Assert.ThrowsAsync( + () => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png")); + + Assert.Contains("new.png", coverStorage.DeletedKeys); + Assert.DoesNotContain("old.png", coverStorage.DeletedKeys); + } + + [Fact] + public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnNullForAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId); + + Assert.Null(editor); + } + + [Fact] + public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnEditorForCoGm() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + EditorResult = new PortfolioGameEditor( + portfolioGameId, + groupId, + "Title", + "slug", + "Description", + "/portfolio-covers/x.png", + "D&D 5e", + "Online", + DateTime.UtcNow, + false, + [], + [], + []) + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId); + + Assert.NotNull(editor); + Assert.Equal("Title", editor!.Title); + } + + [Fact] + public async Task UpdateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + var update = new PortfolioGameUpdate("Updated", "updated-slug", "desc", null, "Online", [], []); + + await Assert.ThrowsAsync( + () => service.UpdateDraftForCurrentUserAsync(portfolioGameId, update)); + Assert.False(portfolioStore.UpdateCalled); + } + + [Fact] + public async Task UpdateDraftForCurrentUserAsync_ShouldNormalizeFieldsBeforeStoring() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + var update = new PortfolioGameUpdate(" Updated ", " my-slug ", " description ", null, " Online ", [], []); + + await service.UpdateDraftForCurrentUserAsync(portfolioGameId, update); + + Assert.True(portfolioStore.UpdateCalled); + Assert.Equal("Updated", portfolioStore.LastUpdateTitle); + Assert.Equal("my-slug", portfolioStore.LastUpdateSlug); + Assert.Equal("description", portfolioStore.LastUpdateDescription); + Assert.Equal("Online", portfolioStore.LastUpdateFormat); + } + + [Fact] + public async Task ModerateReviewForCurrentUserAsync_ShouldResolveEffectivePlayerAndForwardIt() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var reviewId = Guid.NewGuid(); + var effectivePlayerId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + }, + EffectivePlayerId = effectivePlayerId + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await service.ModerateReviewForCurrentUserAsync(portfolioGameId, reviewId, "Approved"); + + Assert.True(portfolioStore.ModerateCalled); + Assert.Equal(reviewId, portfolioStore.LastModerateReviewId); + Assert.Equal(portfolioGameId, portfolioStore.LastModerateGameId); + Assert.Equal(groupId, portfolioStore.LastModerateGroupId); + Assert.Equal(effectivePlayerId, portfolioStore.LastModeratePlayerId); + Assert.Equal("Approved", portfolioStore.LastModerateStatus); + } + + [Fact] + public async Task ModerateReviewForCurrentUserAsync_ShouldRejectAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.ModerateReviewForCurrentUserAsync(portfolioGameId, Guid.NewGuid(), "Approved")); + Assert.False(portfolioStore.ModerateCalled); + } + + [Fact] + public async Task DeleteForCurrentUserAsync_ShouldDeleteCoverAfterRowDeletion() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + DeleteCoverKey = "old.png" + }; + var coverStorage = new FakePortfolioCoverStorage(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await service.DeleteForCurrentUserAsync(portfolioGameId); + + Assert.True(portfolioStore.DeleteCalled); + Assert.Equal(portfolioGameId, portfolioStore.LastDeleteGameId); + Assert.Equal(groupId, portfolioStore.LastDeleteGroupId); + Assert.Contains("old.png", coverStorage.DeletedKeys); + } + + [Fact] + public async Task DeleteForCurrentUserAsync_ShouldStillDeleteRowWhenNoCover() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + DeleteCoverKey = null + }; + var coverStorage = new FakePortfolioCoverStorage(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await service.DeleteForCurrentUserAsync(portfolioGameId); + + Assert.True(portfolioStore.DeleteCalled); + Assert.Empty(coverStorage.DeletedKeys); + } + + [Fact] + public async Task SetPublicationForCurrentUserAsync_ShouldForwardIsPublicFlag() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await service.SetPublicationForCurrentUserAsync(portfolioGameId, isPublic: true); + + Assert.True(portfolioStore.PublicationCalled); + Assert.Equal(portfolioGameId, portfolioStore.LastPublicationGameId); + Assert.Equal(groupId, portfolioStore.LastPublicationGroupId); + Assert.True(portfolioStore.LastPublicationIsPublic); + } + + [Fact] + public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldReturnRequiresAuthForAnonymous() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAnonymousAccessor(); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug"); + + Assert.Equal(PortfolioReviewSubmissionState.RequiresAuthentication, state); + } + + [Fact] + public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldForwardPlatformAndUserId() + { + var portfolioStore = new FakePortfolioStore + { + ReviewStateResult = PortfolioReviewSubmissionState.Eligible + }; + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug"); + + Assert.Equal(PortfolioReviewSubmissionState.Eligible, state); + Assert.Equal("some-slug", portfolioStore.LastReviewStateSlug); + Assert.Equal("Telegram", portfolioStore.LastReviewStatePlatform); + Assert.Equal("1001", portfolioStore.LastReviewStateExternalUserId); + } + + [Fact] + public async Task SubmitReviewForCurrentUserAsync_ShouldRejectAnonymous() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAnonymousAccessor(); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", true)); + Assert.False(portfolioStore.SubmitReviewCalled); + } + + [Fact] + public async Task SubmitReviewForCurrentUserAsync_ShouldRejectMissingConsent() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001", "Alice"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", false)); + Assert.False(portfolioStore.SubmitReviewCalled); + } + + [Fact] + public async Task SubmitReviewForCurrentUserAsync_ShouldNormalizeBodyAndForwardIdentity() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001", "Alice"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await service.SubmitReviewForCurrentUserAsync( + " the-curse-of-strahd ", + " great adventure, would play again ", + true); + + Assert.True(portfolioStore.SubmitReviewCalled); + Assert.Equal("the-curse-of-strahd", portfolioStore.LastSubmitSlug); + Assert.Equal("great adventure, would play again", portfolioStore.LastSubmitBody); + Assert.Equal("Alice", portfolioStore.LastSubmitDisplayName); + Assert.Equal("Telegram", portfolioStore.LastSubmitPlatform); + Assert.Equal("1001", portfolioStore.LastSubmitExternalUserId); + } + + [Fact] + public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnEmptyForNonManager() + { + var groupId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId); + + Assert.Empty(sessions); + Assert.Null(portfolioStore.LastEligibleGroupId); + } + + [Fact] + public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnSessionsForManager() + { + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + EligibleSessions = + [ + new PortfolioSessionOption(sessionId, "Old session", DateTime.UtcNow.AddDays(-7), false) + ] + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId); + + Assert.Single(sessions); + Assert.Equal(sessionId, sessions[0].Id); + } + + [Fact] + public async Task GetPortfolioGamesForCurrentUserAsync_ShouldReturnEmptyForNonManager() + { + var groupId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var games = await service.GetPortfolioGamesForCurrentUserAsync(groupId); + + Assert.Empty(games); + } + + [Fact] + public async Task IDScopedMethod_ShouldThrowWhenPortfolioGameDoesNotExist() + { + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = null } + }; + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.DeleteForCurrentUserAsync(portfolioGameId)); + } + + // --- Fakes --- + + private sealed class FakePortfolioStore : IPortfolioStore + { + public Dictionary PortfolioGameGroupIds { get; set; } = new(); + + public Dictionary GroupIds { get; set; } = new(); + + public Guid CreateDraftResult { get; set; } = Guid.NewGuid(); + + public Guid? LastCreateGroupId { get; private set; } + public Guid? LastCreatePreselectedSessionId { get; private set; } + public bool CreateCalled { get; private set; } + + public PortfolioGameEditor? EditorResult { get; set; } + + public string? CoverPriorKey { get; set; } + public Exception? SetCoverThrows { get; set; } + public Guid? LastSetCoverGameId { get; private set; } + public Guid? LastSetCoverGroupId { get; private set; } + public string? LastSetCoverKey { get; private set; } + + public bool UpdateCalled { get; private set; } + public Guid? LastUpdateGameId { get; private set; } + public Guid? LastUpdateGroupId { get; private set; } + public string? LastUpdateTitle { get; private set; } + public string? LastUpdateSlug { get; private set; } + public string? LastUpdateDescription { get; private set; } + public string? LastUpdateFormat { get; private set; } + + public bool DeleteCalled { get; private set; } + public Guid? LastDeleteGameId { get; private set; } + public Guid? LastDeleteGroupId { get; private set; } + public string? DeleteCoverKey { get; set; } + + public bool PublicationCalled { get; private set; } + public Guid? LastPublicationGameId { get; private set; } + public Guid? LastPublicationGroupId { get; private set; } + public bool? LastPublicationIsPublic { get; private set; } + + public bool ModerateCalled { get; private set; } + public Guid? LastModerateReviewId { get; private set; } + public Guid? LastModerateGameId { get; private set; } + public Guid? LastModerateGroupId { get; private set; } + public Guid? LastModeratePlayerId { get; private set; } + public string? LastModerateStatus { get; private set; } + + public IReadOnlyList EligibleSessions { get; set; } = []; + public Guid? LastEligibleGroupId { get; private set; } + + public IReadOnlyList GamesForGroup { get; set; } = []; + + public PortfolioReviewSubmissionState ReviewStateResult { get; set; } = PortfolioReviewSubmissionState.Ineligible; + public string? LastReviewStateSlug { get; private set; } + public string? LastReviewStatePlatform { get; private set; } + public string? LastReviewStateExternalUserId { get; private set; } + + public bool SubmitReviewCalled { get; private set; } + public string? LastSubmitSlug { get; private set; } + public string? LastSubmitBody { get; private set; } + public string? LastSubmitDisplayName { get; private set; } + public string? LastSubmitPlatform { get; private set; } + public string? LastSubmitExternalUserId { get; private set; } + + public Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug) => + Task.FromResult>([]); + + public Task> GetPublicPortfolioGamesForClubAsync(string clubSlug) => + Task.FromResult>([]); + + public Task GetPublicPortfolioGameBySlugAsync(string slug) => + Task.FromResult(null); + + public Task> GetPortfolioGamesForGroupAsync(Guid groupId) + { + LastEligibleGroupId = groupId; + return Task.FromResult(GamesForGroup); + } + + public Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId) + { + PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId); + return Task.FromResult(groupId); + } + + public Task GetPortfolioGameForManagementAsync(Guid portfolioGameId) => + Task.FromResult(EditorResult); + + public Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId) + { + LastEligibleGroupId = groupId; + return Task.FromResult(EligibleSessions); + } + + public Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) => + Task.FromResult>([]); + + public Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId) + { + CreateCalled = true; + LastCreateGroupId = groupId; + LastCreatePreselectedSessionId = preselectedSessionId; + return Task.FromResult(CreateDraftResult); + } + + public Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update) + { + UpdateCalled = true; + LastUpdateGameId = portfolioGameId; + LastUpdateGroupId = groupId; + LastUpdateTitle = update.Title; + LastUpdateSlug = update.PublicSlug; + LastUpdateDescription = update.Description; + LastUpdateFormat = update.Format; + return Task.CompletedTask; + } + + public Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey) + { + LastSetCoverGameId = portfolioGameId; + LastSetCoverGroupId = groupId; + LastSetCoverKey = storageKey; + + if (SetCoverThrows is not null) + { + throw SetCoverThrows; + } + + return Task.FromResult(CoverPriorKey); + } + + public Task DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId) + { + DeleteCalled = true; + LastDeleteGameId = portfolioGameId; + LastDeleteGroupId = groupId; + return Task.FromResult(DeleteCoverKey); + } + + public Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic) + { + PublicationCalled = true; + LastPublicationGameId = portfolioGameId; + LastPublicationGroupId = groupId; + LastPublicationIsPublic = isPublic; + return Task.CompletedTask; + } + + public Task ModeratePortfolioReviewAsync( + Guid reviewId, + Guid portfolioGameId, + Guid groupId, + Guid moderatorPlayerId, + string moderationStatus) + { + ModerateCalled = true; + LastModerateReviewId = reviewId; + LastModerateGameId = portfolioGameId; + LastModerateGroupId = groupId; + LastModeratePlayerId = moderatorPlayerId; + LastModerateStatus = moderationStatus; + return Task.CompletedTask; + } + + public Task GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId) + { + LastReviewStateSlug = slug; + LastReviewStatePlatform = platform; + LastReviewStateExternalUserId = externalUserId; + return Task.FromResult(ReviewStateResult); + } + + public Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body) + { + SubmitReviewCalled = true; + LastSubmitSlug = slug; + LastSubmitPlatform = platform; + LastSubmitExternalUserId = externalUserId; + LastSubmitDisplayName = displayName; + LastSubmitBody = body; + return Task.CompletedTask; + } + } + + private sealed class FakeSessionStore : ISessionStore + { + public Dictionary<(Guid GroupId, string Platform, string ExternalUserId), bool> ManagerFlags { get; set; } = new(); + + public Guid? EffectivePlayerId { get; set; } = Guid.NewGuid(); + + public Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) + { + if (ManagerFlags.TryGetValue((groupId, platform, externalUserId), out var flag)) + { + return Task.FromResult(flag); + } + return Task.FromResult(false); + } + + public Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId) => + Task.FromResult(EffectivePlayerId); + + // Unused interface members — throw so accidental use surfaces in test output + public Task> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task GetGroupAsync(Guid groupId) => throw new NotImplementedException(); + public Task GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException(); + public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException(); + public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException(); + public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException(); + public Task GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException(); + public Task GetPublicSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException(); + public Task> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException(); + public Task> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException(); + public Task GetSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task GetBatchAsync(Guid batchId) => throw new NotImplementedException(); + public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException(); + public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException(); + public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException(); + public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException(); + public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) => throw new NotImplementedException(); + public Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException(); + public Task> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException(); + public Task GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException(); + public Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException(); + public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException(); + public Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) => throw new NotImplementedException(); + public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) => throw new NotImplementedException(); + public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) => throw new NotImplementedException(); + public Task> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException(); + public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException(); + public Task> GetGroupAttendanceStatsAsync(Guid groupId) => throw new NotImplementedException(); + public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) => throw new NotImplementedException(); + public Task> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException(); + public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException(); + public Task GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException(); + public Task GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException(); + public Task> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException(); + public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException(); + public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => throw new NotImplementedException(); + public Task> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException(); + public Task GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => throw new NotImplementedException(); + } + + private sealed class FakePortfolioCoverStorage : IPortfolioCoverStorage + { + public string SaveKey { get; set; } = Guid.NewGuid().ToString("N") + ".png"; + public List SavedKeys { get; } = new(); + public List DeletedKeys { get; } = new(); + + public Task SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default) + { + SavedKeys.Add(SaveKey); + return Task.FromResult(new PortfolioCoverUploadResult(SaveKey, contentType)); + } + + public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default) + { + DeletedKeys.Add(storageKey); + return Task.CompletedTask; + } + + public string GetPublicPath(string storageKey) => "/portfolio-covers/" + storageKey; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs index cf5f0ed..d3e36c2 100644 --- a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -1,5 +1,3 @@ -using System.Xml.Linq; - namespace GmRelay.Bot.Tests.Web; public sealed class CampaignTemplatesNavigationTests @@ -17,14 +15,7 @@ public sealed class CampaignTemplatesNavigationTests public async Task NavMenu_ShouldExposeCurrentProjectVersion() { var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); - var props = XDocument.Load(FindRepositoryFile("Directory.Build.props")); - var version = props.Root? - .Element("PropertyGroup")? - .Element("Version")? - .Value; - - Assert.False(string.IsNullOrWhiteSpace(version)); - Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal); + Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal); } [Fact] diff --git a/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs b/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs new file mode 100644 index 0000000..bf7513d --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs @@ -0,0 +1,172 @@ +using GmRelay.Web.Services.Portfolio.Covers; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class LocalPortfolioCoverStorageTests : IDisposable +{ + private readonly string _storagePath; + private readonly LocalPortfolioCoverStorage _storage; + + public LocalPortfolioCoverStorageTests() + { + _storagePath = Path.Combine( + Path.GetTempPath(), + "gmrelay-portfolio-covers-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_storagePath); + _storage = new LocalPortfolioCoverStorage(new PortfolioCoverStorageOptions + { + StoragePath = _storagePath + }); + } + + public void Dispose() + { + if (Directory.Exists(_storagePath)) + { + Directory.Delete(_storagePath, recursive: true); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey() + { + await using var stream = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]); + + var result = await _storage.SaveAsync(stream, "image/png"); + + Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal); + Assert.StartsWith("/portfolio-covers/", _storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal); + Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey))); + } + + [Theory] + [InlineData("image/jpeg")] + [InlineData("image/png")] + [InlineData("image/webp")] + public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType) + { + await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]); + + await Assert.ThrowsAsync( + () => _storage.SaveAsync(stream, contentType)); + } + + [Fact] + public async Task SaveAsync_ShouldPersistJpegWithCorrectSignature() + { + await using var stream = new MemoryStream( + [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]); + + var result = await _storage.SaveAsync(stream, "image/jpeg"); + + Assert.EndsWith(".jpg", result.StorageKey, StringComparison.Ordinal); + Assert.Equal("image/jpeg", result.ContentType); + Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey))); + } + + [Fact] + public async Task SaveAsync_ShouldPersistWebpWithRiffWebpSignature() + { + await using var stream = new MemoryStream( + [0x52, 0x49, 0x46, 0x46, 0x1A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38]); + + var result = await _storage.SaveAsync(stream, "image/webp"); + + Assert.EndsWith(".webp", result.StorageKey, StringComparison.Ordinal); + Assert.Equal("image/webp", result.ContentType); + Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey))); + } + + [Fact] + public async Task SaveAsync_ShouldRejectStreamLargerThanMaxBytes() + { + var oversized = new byte[LocalPortfolioCoverStorage.MaxBytes + 1]; + await using var stream = new MemoryStream(oversized); + + await Assert.ThrowsAsync( + () => _storage.SaveAsync(stream, "image/png")); + } + + [Fact] + public async Task SaveAsync_ShouldRejectUnknownContentType() + { + await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]); + + await Assert.ThrowsAsync( + () => _storage.SaveAsync(stream, "application/octet-stream")); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRemoveExistingKey() + { + await using var stream = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + var result = await _storage.SaveAsync(stream, "image/png"); + var path = Path.Combine(_storagePath, result.StorageKey); + Assert.True(File.Exists(path)); + + await _storage.DeleteIfExistsAsync(result.StorageKey); + + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldBeNoOpForMissingKey() + { + var key = Guid.NewGuid().ToString("N") + ".png"; + + await _storage.DeleteIfExistsAsync(key); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRejectPathTraversal() + { + await Assert.ThrowsAsync( + () => _storage.DeleteIfExistsAsync("../escape.png")); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension() + { + await Assert.ThrowsAsync( + () => _storage.DeleteIfExistsAsync(Guid.NewGuid().ToString("N") + ".gif")); + } + + [Fact] + public void GetPublicPath_ShouldEscapeSpecialCharacters() + { + var key = "0123456789abcdef0123456789abcdef" + ".png"; + + var path = _storage.GetPublicPath(key); + + Assert.Equal("/portfolio-covers/" + Uri.EscapeDataString(key), path); + } + + [Fact] + public async Task SaveAsync_ShouldGenerateUniqueKeys() + { + await using var stream1 = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + await using var stream2 = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + var first = await _storage.SaveAsync(stream1, "image/png"); + var second = await _storage.SaveAsync(stream2, "image/png"); + + Assert.NotEqual(first.StorageKey, second.StorageKey); + } + + [Fact] + public async Task SaveAsync_ShouldNotLeaveTempFileBehind() + { + await using var stream = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + await _storage.SaveAsync(stream, "image/png"); + + var tempFiles = Directory.GetFiles(_storagePath, "*.tmp"); + Assert.Empty(tempFiles); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs new file mode 100644 index 0000000..569ebe5 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs @@ -0,0 +1,120 @@ +using GmRelay.Web.Services.Portfolio; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioContractsTests +{ + [Fact] + public void PublicPortfolioCard_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioGame_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioMaster_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioReview_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioCard_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioCard).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("Title", names); + Assert.Contains("CoverPath", names); + Assert.Contains("System", names); + Assert.Contains("Format", names); + Assert.Contains("CompletedAt", names); + } + + [Fact] + public void PublicPortfolioGame_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioGame).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("Title", names); + Assert.Contains("Description", names); + Assert.Contains("CoverPath", names); + Assert.Contains("System", names); + Assert.Contains("Format", names); + Assert.Contains("CompletedAt", names); + Assert.Contains("ClubName", names); + Assert.Contains("ClubSlug", names); + Assert.Contains("Masters", names); + Assert.Contains("Reviews", names); + } + + [Fact] + public void PublicPortfolioMaster_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioMaster).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("DisplayName", names); + } + + [Fact] + public void PublicPortfolioReview_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioReview).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("AuthorDisplayName", names); + Assert.Contains("Body", names); + Assert.Contains("CreatedAt", names); + } + + [Fact] + public void IPortfolioStore_ShouldExposeAllRequiredMethods() + { + var interfaceType = typeof(IPortfolioStore); + var methodNames = interfaceType.GetMethods().Select(m => m.Name).ToArray(); + + Assert.Contains("GetPublicPortfolioGamesForMasterAsync", methodNames); + Assert.Contains("GetPublicPortfolioGamesForClubAsync", methodNames); + Assert.Contains("GetPublicPortfolioGameBySlugAsync", methodNames); + Assert.Contains("GetPortfolioGamesForGroupAsync", methodNames); + Assert.Contains("GetPortfolioGameGroupIdAsync", methodNames); + Assert.Contains("GetPortfolioGameForManagementAsync", methodNames); + Assert.Contains("GetEligibleCompletedSessionsAsync", methodNames); + Assert.Contains("GetPortfolioMasterOptionsAsync", methodNames); + Assert.Contains("CreatePortfolioDraftAsync", methodNames); + Assert.Contains("UpdatePortfolioDraftAsync", methodNames); + Assert.Contains("SetPortfolioCoverAsync", methodNames); + Assert.Contains("DeletePortfolioGameAsync", methodNames); + Assert.Contains("SetPortfolioPublicationAsync", methodNames); + Assert.Contains("ModeratePortfolioReviewAsync", methodNames); + Assert.Contains("GetReviewSubmissionStateAsync", methodNames); + Assert.Contains("SubmitPortfolioReviewAsync", methodNames); + } + + private static void AssertNoForbiddenPropertyNames() + { + var forbidden = new[] + { + "Id", "External", "Telegram", "Discord", "Moderator", + "StorageKey", "PhysicalPath", "JoinLink", "Session" + }; + + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + + foreach (var forbiddenFragment in forbidden) + { + Assert.DoesNotContain(names, name => name.Contains(forbiddenFragment, StringComparison.Ordinal)); + } + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs new file mode 100644 index 0000000..a7429e9 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs @@ -0,0 +1,73 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioCoverRuntimeWiringTests +{ + [Fact] + public async Task Program_ShouldRegisterPortfolioCoverStorage() + { + var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs"); + + Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal); + Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal); + } + + [Fact] + public async Task Compose_ShouldMountPortfolioCoversVolumeAndPassStoragePath() + { + var compose = await ReadRepositoryFileAsync("compose.yaml"); + + Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal); + Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal); + } + + [Fact] + public async Task Dockerfile_ShouldCreateAndChownPortfolioCoversDirectory() + { + var dockerfile = await ReadRepositoryFileAsync("src/GmRelay.Web/Dockerfile"); + + Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal); + Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal); + } + + [Fact] + public async Task DevelopmentSettings_ShouldConfigurePortfolioCoversStoragePath() + { + var developmentSettings = await ReadRepositoryFileAsync("src/GmRelay.Web/appsettings.Development.json"); + + Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal); + } + + [Fact] + public async Task EnvExample_ShouldDocumentPortfolioCoversVolumeName() + { + var envExample = await ReadRepositoryFileAsync(".env.example"); + + Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", envExample, StringComparison.Ordinal); + } + + [Fact] + public async Task Compose_ShouldDeclarePortfolioCoversNamedVolume() + { + var compose = await ReadRepositoryFileAsync("compose.yaml"); + + Assert.Contains("portfolio_covers:", compose, StringComparison.Ordinal); + Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", compose, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs new file mode 100644 index 0000000..e80468c --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs @@ -0,0 +1,90 @@ +using Npgsql; +using Testcontainers.PostgreSql; + +namespace GmRelay.Bot.Tests.Web; + +[CollectionDefinition(Name)] +public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture +{ + public const string Name = "Portfolio migration PostgreSQL"; +} + +public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime +{ + private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2); + private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + + public Task InitializeAsync() + { + return container.StartAsync().WaitAsync(ContainerTimeout); + } + + public Task DisposeAsync() + { + return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout); + } + + public async Task CreateMigratedDatabaseAsync() + { + var databaseName = $"portfolio_{Guid.NewGuid():N}"; + + await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString())) + { + await adminConnection.OpenAsync().WaitAsync(ContainerTimeout); + await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection); + await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); + } + + var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString()) + { + Database = databaseName, + Timeout = 10, + CommandTimeout = 10 + }.ConnectionString; + + var migrations = GetMigrationPaths(); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync().WaitAsync(ContainerTimeout); + + foreach (var migration in migrations) + { + await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection) + { + CommandTimeout = 30 + }; + await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); + } + + return new MigratedPortfolioDatabase(connectionString, migrations.Count); + } + + private static IReadOnlyList GetMigrationPaths() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations"); + if (Directory.Exists(migrationsDirectory)) + { + return Directory.GetFiles(migrationsDirectory, "V*.sql") + .Where(path => string.CompareOrdinal(Path.GetFileName(path), "V030__") < 0) + .OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal) + .ToArray(); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the bot migrations directory."); + } +} + +public sealed record MigratedPortfolioDatabase(string ConnectionString, int AppliedMigrationCount) +{ + public async Task OpenConnectionAsync() + { + var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10)); + return connection; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs new file mode 100644 index 0000000..27e5eac --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -0,0 +1,1198 @@ +using Npgsql; +using System.Data; + +namespace GmRelay.Bot.Tests.Web; + +[Collection(PortfolioMigrationPostgresCollection.Name)] +public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFixture fixture) +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); + private static long nextLegacyId = 1000; + + [Fact] + public async Task MigrationsV001ThroughV029_ShouldApplyToPostgres17() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + + Assert.Equal(29, database.AppliedMigrationCount); + + await using var connection = await database.OpenConnectionAsync(); + Assert.Equal(4, await ExecuteScalarAsync( + connection, + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'portfolio_games', + 'portfolio_game_sessions', + 'portfolio_game_masters', + 'portfolio_game_reviews') + """)); + } + + [Theory] + [InlineData("portfolio_game_sessions")] + [InlineData("portfolio_game_masters")] + public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(string linkTable) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Fact] + public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + await ExecuteNonQueryAsync( + connection, + "DELETE FROM sessions WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + await transaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.False(await ExecuteScalarAsync( + connection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( + connection, + "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(0, await ExecuteScalarAsync( + connection, + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConcurrentPublishAndLinkDelete_ShouldSerializeBeforeRowsAndRejectInvalidPublicCard( + bool publishMutatesFirst) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var deleteConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(publishConnection, isPublic: false); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); + + if (publishMutatesFirst) + { + Assert.Equal(1, await PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId)); + var deleteTask = DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId); + + await WaitUntilBlockedByAsync(observerConnection, deletePid, publishPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await deleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + } + else + { + Assert.Equal(1, await DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId)); + var publishTask = PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId); + + await WaitUntilBlockedByAsync(observerConnection, publishPid, deletePid); + Assert.Null(await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await publishTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + } + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.Equal(publishMutatesFirst, await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(publishMutatesFirst ? 1L : 0L, await ExecuteScalarAsync( + verificationConnection, + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Theory] + [InlineData("portfolio_game_sessions", "session_id")] + [InlineData("portfolio_game_masters", "player_id")] + public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidPublicCard( + string linkTable, + string linkColumn) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync( + seedConnection, + isPublic: true, + sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1, + masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); + await using var firstConnection = await database.OpenConnectionAsync(); + await using var secondConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var firstTransaction = await firstConnection.BeginTransactionAsync(); + await using var secondTransaction = await secondConnection.BeginTransactionAsync(); + var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); + var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); + var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; + + await ExecuteNonQueryAsync( + firstConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + firstTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[0])); + var secondDeleteTask = ExecuteNonQueryAsync( + secondConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + secondTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[1])); + + await WaitUntilBlockedByAsync(observerConnection, secondPid, firstPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(firstTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await secondDeleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(secondTransaction).WaitAsync(CommandTimeout)); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(1, await ExecuteScalarAsync( + verificationConnection, + $"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Theory] + [InlineData("portfolio_game_sessions", "session_id")] + [InlineData("portfolio_game_masters", "player_id")] + public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard( + string linkTable, + string linkColumn) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync( + seedConnection, + isPublic: true, + sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1, + masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); + await using var firstConnection = await database.OpenConnectionAsync(); + await using var secondConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var firstTransaction = await firstConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + await using var secondTransaction = await secondConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); + var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); + var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; + + await ExecuteNonQueryAsync( + firstConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + firstTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[0])); + var secondDeleteTask = ExecuteNonQueryAsync( + secondConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + secondTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[1])); + + await WaitUntilBlockedByAsync(observerConnection, secondPid, firstPid); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(firstTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await secondDeleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(secondTransaction).WaitAsync(CommandTimeout)); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(2, await ExecuteScalarAsync( + verificationConnection, + $"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Theory] + [InlineData(IsolationLevel.RepeatableRead)] + [InlineData(IsolationLevel.Serializable)] + public async Task NonReadCommittedPublishedCardLinkDelete_ShouldBeRejected(IsolationLevel isolationLevel) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(isolationLevel); + + await ExecuteNonQueryAsync( + connection, + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard( + bool deleteMutatesFirst) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + await using var deleteConnection = await database.OpenConnectionAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + + if (deleteMutatesFirst) + { + Assert.Equal(1, await DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId)); + var publishTask = PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId); + + await WaitUntilBlockedByAsync(observerConnection, publishPid, deletePid); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await publishTask.WaitAsync(CommandTimeout)); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + } + else + { + Assert.Equal(1, await PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId)); + var deleteTask = DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId); + + await WaitUntilBlockedByAsync(observerConnection, deletePid, publishPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await deleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + } + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(1, await ExecuteScalarAsync( + verificationConnection, + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + Assert.False(await ExecuteScalarAsync( + connection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( + connection, + "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() - interval '2 days' WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + await transaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.True(await ExecuteScalarAsync( + connection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( + connection, + "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task ConcurrentBatchFutureReschedules_ShouldSerializeBeforeSessionRowsWithoutDeadlock() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var firstSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); + var secondSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); + + await using var firstConnection = await database.OpenConnectionAsync(); + await using var secondConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var firstTransaction = await firstConnection.BeginTransactionAsync(); + await using var secondTransaction = await secondConnection.BeginTransactionAsync(); + var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); + var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); + + await RescheduleSessionsAsync( + firstConnection, + firstTransaction, + firstSeed.SessionIds[0], + secondSeed.SessionIds[0]); + var secondRescheduleTask = RescheduleSessionsAsync( + secondConnection, + secondTransaction, + secondSeed.SessionIds[1], + firstSeed.SessionIds[1]); + + await WaitUntilBlockedByAsync(observerConnection, secondPid, firstPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(firstTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(2, await secondRescheduleTask.WaitAsync(CommandTimeout)); + Assert.Null(await CommitAndCaptureSqlStateAsync(secondTransaction).WaitAsync(CommandTimeout)); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.Equal(0, await ExecuteScalarAsync( + verificationConnection, + """ + SELECT COUNT(*) + FROM portfolio_games + WHERE id IN (@firstPortfolioGameId, @secondPortfolioGameId) + AND is_public = true + """, + parameters: + [ + new NpgsqlParameter("firstPortfolioGameId", firstSeed.PortfolioGameId), + new NpgsqlParameter("secondPortfolioGameId", secondSeed.PortfolioGameId) + ])); + } + + [Fact] + public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: false, sessionCount: 2); + + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[1])); + await using var transaction = await connection.BeginTransactionAsync(); + await ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Fact] + public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(publishConnection, isPublic: false); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + + Assert.Equal(1, await PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId)); + var rescheduleTask = ExecuteNonQueryAsync( + rescheduleConnection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + rescheduleTransaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, publishPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await rescheduleTask.WaitAsync(CommandTimeout)); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); + } + + [Fact] + public async Task ConcurrentNewLinkPublishAndFutureReschedule_ShouldNotCommitInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + var rescheduledSessionId = Guid.NewGuid(); + await ExecuteNonQueryAsync( + seedConnection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + """, + parameters: + [ + new NpgsqlParameter("sessionId", rescheduledSessionId), + new NpgsqlParameter("groupId", seed.GroupId) + ]); + + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + + Assert.Equal(1, await RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + rescheduledSessionId)); + var forceRescheduleTriggerTask = ExecuteNonQueryAsync( + rescheduleConnection, + "SET CONSTRAINTS trg_sessions_unpublish_public_portfolio_games_for_future_reschedule IMMEDIATE", + rescheduleTransaction); + await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); + + var publishMutationTask = ExecuteNonQueryAsync( + publishConnection, + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId; + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId)); + await WaitUntilBlockedByAsync(observerConnection, publishPid, reschedulePid); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + await publishMutationTask.WaitAsync(CommandTimeout); + + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", rescheduledSessionId))); + Assert.Equal(0, await ExecuteScalarAsync( + verificationConnection, + """ + SELECT COUNT(*) + FROM portfolio_game_sessions + WHERE portfolio_game_id = @portfolioGameId + AND session_id = @sessionId + """, + parameters: + [ + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId) + ])); + } + + [Fact] + public async Task PortfolioSessionLinkInsert_ShouldAcquirePublicationLockBeforeRows() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + var sessionId = Guid.NewGuid(); + await ExecuteNonQueryAsync( + seedConnection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + """, + parameters: + [ + new NpgsqlParameter("sessionId", sessionId), + new NpgsqlParameter("groupId", seed.GroupId) + ]); + + await using var insertConnection = await database.OpenConnectionAsync(); + await using var gateConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var insertTransaction = await insertConnection.BeginTransactionAsync(); + await using var gateTransaction = await gateConnection.BeginTransactionAsync(); + var insertPid = await GetBackendPidAsync(insertConnection, insertTransaction); + var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); + await AcquirePortfolioValidationLockAsync(gateConnection, gateTransaction); + + var insertTask = ExecuteNonQueryAsync( + insertConnection, + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + """, + insertTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", sessionId)); + + await WaitUntilBlockedByAsync(observerConnection, insertPid, gatePid); + await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(1, await insertTask.WaitAsync(CommandTimeout)); + await insertTransaction.RollbackAsync().WaitAsync(CommandTimeout); + } + + [Fact] + public async Task FutureReschedule_ShouldAcquirePublicationLockBeforeSessionRows() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: true); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var gateConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + await using var gateTransaction = await gateConnection.BeginTransactionAsync(); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); + await AcquirePortfolioValidationLockAsync(gateConnection, gateTransaction); + + var rescheduleTask = RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + seed.SessionIds[0]); + + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, gatePid); + await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(1, await rescheduleTask.WaitAsync(CommandTimeout)); + await rescheduleTransaction.RollbackAsync().WaitAsync(CommandTimeout); + } + + [Fact] + public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + var rescheduledSessionId = Guid.NewGuid(); + await ExecuteNonQueryAsync( + seedConnection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + """, + parameters: + [ + new NpgsqlParameter("sessionId", rescheduledSessionId), + new NpgsqlParameter("groupId", seed.GroupId) + ]); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + + Assert.Equal(0, await ExecuteScalarAsync( + rescheduleConnection, + """ + SELECT COUNT(*) + FROM portfolio_game_sessions + WHERE portfolio_game_id = @portfolioGameId + AND session_id = @sessionId + """, + rescheduleTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId))); + + await using (var publishTransaction = await publishConnection.BeginTransactionAsync()) + { + await ExecuteNonQueryAsync( + publishConnection, + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId)); + await publishTransaction.CommitAsync().WaitAsync(CommandTimeout); + } + + Assert.Equal(1, await RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + rescheduledSessionId)); + + var exception = await Assert.ThrowsAsync( + () => rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", rescheduledSessionId))); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeMutationGateBeforeRowsWithoutDeadlock( + bool deleteMutatesFirst) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: true); + await using var deleteConnection = await database.OpenConnectionAsync(); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + + if (deleteMutatesFirst) + { + await AcquirePortfolioValidationLockAsync(deleteConnection, deleteTransaction); + await LockSessionAsync(deleteConnection, deleteTransaction, seed.SessionIds[0]); + var rescheduleTask = RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + seed.SessionIds[0]); + + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, deletePid); + await UnpublishAndDeleteSessionAsync( + deleteConnection, + deleteTransaction, + seed.PortfolioGameId, + seed.SessionIds[0]); + await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(0, await rescheduleTask.WaitAsync(CommandTimeout)); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + } + else + { + Assert.Equal(1, await RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + seed.SessionIds[0])); + var deleteTask = LockUnpublishDeleteAndCommitSessionAsync( + deleteConnection, + deleteTransaction, + seed.PortfolioGameId, + seed.SessionIds[0]); + + await WaitUntilBlockedByAsync(observerConnection, deletePid, reschedulePid); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + await deleteTask.WaitAsync(CommandTimeout); + } + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(0, await ExecuteScalarAsync( + verificationConnection, + "SELECT COUNT(*) FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); + } + + [Theory] + [InlineData("portfolio_game_sessions")] + [InlineData("portfolio_game_masters")] + public async Task MovingLastRequiredLinkAway_ShouldFailCommitForPublishedCard(string linkTable) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var source = await SeedCardAsync(connection, isPublic: true); + var destination = await SeedCardAsync(connection, isPublic: false); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + $"UPDATE {linkTable} SET portfolio_game_id = @destinationId WHERE portfolio_game_id = @sourceId", + transaction, + new NpgsqlParameter("destinationId", destination.PortfolioGameId), + new NpgsqlParameter("sourceId", source.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Theory] + [InlineData("sessions")] + [InlineData("players")] + public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(string parentTable) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + $"DELETE FROM {parentTable} WHERE id = @parentId", + transaction, + new NpgsqlParameter( + "parentId", + parentTable == "sessions" ? seed.SessionIds[0] : seed.MasterIds[0])); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Fact] + public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var cardDeleteSeed = await SeedCardAsync(connection, isPublic: true); + + await ExecuteNonQueryAsync( + connection, + "DELETE FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", cardDeleteSeed.PortfolioGameId)); + + var groupDeleteSeed = await SeedCardAsync(connection, isPublic: true); + await ExecuteNonQueryAsync( + connection, + "DELETE FROM game_groups WHERE id = @groupId", + parameters: new NpgsqlParameter("groupId", groupDeleteSeed.GroupId)); + + Assert.Equal(0, await ExecuteScalarAsync( + connection, + "SELECT COUNT(*) FROM portfolio_games WHERE id IN (@cardDeleteId, @groupDeleteId)", + parameters: + [ + new NpgsqlParameter("cardDeleteId", cardDeleteSeed.PortfolioGameId), + new NpgsqlParameter("groupDeleteId", groupDeleteSeed.PortfolioGameId) + ])); + } + + private static async Task SeedCardAsync( + NpgsqlConnection connection, + bool isPublic, + int sessionCount = 1, + int masterCount = 1) + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var sessionIds = Enumerable.Range(0, sessionCount).Select(_ => Guid.NewGuid()).ToArray(); + var masterIds = Enumerable.Range(0, masterCount).Select(_ => Guid.NewGuid()).ToArray(); + var publishedAtValue = DateTime.UtcNow.AddDays(-1); + var publishedAt = new DateTime(publishedAtValue.Ticks / 10 * 10, DateTimeKind.Utc); + await using var transaction = await connection.BeginTransactionAsync(); + + foreach (var masterId in masterIds) + { + var legacyId = Interlocked.Increment(ref nextLegacyId); + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO players (id, telegram_id, display_name, platform, external_user_id) + VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText); + """, + transaction, + new NpgsqlParameter("playerId", masterId), + new NpgsqlParameter("legacyId", legacyId), + new NpgsqlParameter("legacyIdText", legacyId.ToString())); + } + + var groupLegacyId = Interlocked.Increment(ref nextLegacyId); + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO game_groups (id, telegram_chat_id, name, gm_telegram_id, platform, external_group_id) + VALUES (@groupId, @legacyId, 'Portfolio Club', @legacyId, 'Telegram', @legacyIdText); + + INSERT INTO portfolio_games ( + id, + group_id, + public_slug, + title, + description, + cover_storage_key, + is_public, + published_at) + VALUES ( + @portfolioGameId, + @groupId, + @publicSlug, + 'Completed Adventure', + 'A completed adventure.', + 'covers/example.webp', + @isPublic, + CASE WHEN @isPublic THEN @publishedAt ELSE NULL END); + """, + transaction, + new NpgsqlParameter("legacyId", groupLegacyId), + new NpgsqlParameter("legacyIdText", groupLegacyId.ToString()), + new NpgsqlParameter("groupId", groupId), + new NpgsqlParameter("portfolioGameId", portfolioGameId), + new NpgsqlParameter("publicSlug", $"portfolio-{portfolioGameId:N}"), + new NpgsqlParameter("isPublic", isPublic), + new NpgsqlParameter("publishedAt", publishedAt)); + + foreach (var sessionId in sessionIds) + { + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + """, + transaction, + new NpgsqlParameter("sessionId", sessionId), + new NpgsqlParameter("groupId", groupId), + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + } + + foreach (var masterId in masterIds) + { + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) + VALUES (@portfolioGameId, @playerId); + """, + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId), + new NpgsqlParameter("playerId", masterId)); + } + + await transaction.CommitAsync().WaitAsync(CommandTimeout); + return new PortfolioSeed(portfolioGameId, groupId, sessionIds, masterIds, publishedAt); + } + + private static async Task CommitAndCaptureSqlStateAsync(NpgsqlTransaction transaction) + { + try + { + await transaction.CommitAsync().WaitAsync(CommandTimeout); + return null; + } + catch (PostgresException exception) + { + return exception.SqlState; + } + } + + private static Task AcquirePortfolioValidationLockAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction) + { + return ExecuteNonQueryAsync( + connection, + "SELECT pg_advisory_xact_lock(20260530, 108)", + transaction); + } + + private static Task GetBackendPidAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction) + { + return ExecuteScalarAsync(connection, "SELECT pg_backend_pid()", transaction); + } + + private static Task LockSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid sessionId) + { + return ExecuteNonQueryAsync( + connection, + "SELECT 1 FROM sessions s WHERE s.id = @sessionId FOR UPDATE OF s", + transaction, + new NpgsqlParameter("sessionId", sessionId)); + } + + private static Task PublishPortfolioGameAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid portfolioGameId) + { + return ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + } + + private static Task DeletePortfolioGameLinksAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string linkTable, + Guid portfolioGameId) + { + return ExecuteNonQueryAsync( + connection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + } + + private static Task RescheduleSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid sessionId) + { + return ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", sessionId)); + } + + private static Task RescheduleSessionsAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid firstSessionId, + Guid secondSessionId) + { + return ExecuteNonQueryAsync( + connection, + """ + UPDATE sessions + SET scheduled_at = now() + interval '1 day' + WHERE id = @firstSessionId; + + UPDATE sessions + SET scheduled_at = now() + interval '1 day' + WHERE id = @secondSessionId; + """, + transaction, + new NpgsqlParameter("firstSessionId", firstSessionId), + new NpgsqlParameter("secondSessionId", secondSessionId)); + } + + private static async Task LockUnpublishDeleteAndCommitSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid portfolioGameId, + Guid sessionId) + { + await AcquirePortfolioValidationLockAsync(connection, transaction); + await LockSessionAsync(connection, transaction, sessionId); + await UnpublishAndDeleteSessionAsync(connection, transaction, portfolioGameId, sessionId); + await transaction.CommitAsync().WaitAsync(CommandTimeout); + } + + private static async Task UnpublishAndDeleteSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid portfolioGameId, + Guid sessionId) + { + await ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + await ExecuteNonQueryAsync( + connection, + "DELETE FROM sessions WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", sessionId)); + } + + private static async Task WaitUntilBlockedByAsync( + NpgsqlConnection observerConnection, + int blockedPid, + int blockingPid) + { + using var timeout = new CancellationTokenSource(CommandTimeout); + while (!timeout.IsCancellationRequested) + { + if (await ExecuteScalarAsync( + observerConnection, + "SELECT @blockingPid = ANY (pg_blocking_pids(@blockedPid))", + parameters: + [ + new NpgsqlParameter("blockedPid", blockedPid), + new NpgsqlParameter("blockingPid", blockingPid) + ])) + { + return; + } + + await Task.Yield(); + } + + throw new TimeoutException( + $"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}."); + } + + private static async Task ExecuteNonQueryAsync( + NpgsqlConnection connection, + string sql, + NpgsqlTransaction? transaction = null, + params NpgsqlParameter[] parameters) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + command.Parameters.AddRange(parameters); + return await command.ExecuteNonQueryAsync().WaitAsync(CommandTimeout); + } + + private static async Task ExecuteScalarAsync( + NpgsqlConnection connection, + string sql, + NpgsqlTransaction? transaction = null, + params NpgsqlParameter[] parameters) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + command.Parameters.AddRange(parameters); + return (T)(await command.ExecuteScalarAsync().WaitAsync(CommandTimeout))!; + } + + private sealed record PortfolioSeed( + Guid PortfolioGameId, + Guid GroupId, + Guid[] SessionIds, + Guid[] MasterIds, + DateTime PublishedAt); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs new file mode 100644 index 0000000..28f42fa --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -0,0 +1,103 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioMigrationTests +{ + [Fact] + public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + var normalizedMigration = NormalizeSql(migration); + var unpublishFunctionStart = normalizedMigration.IndexOf( + "CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()", + StringComparison.Ordinal); + var unpublishFunctionEnd = normalizedMigration.IndexOf( + "CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule", + StringComparison.Ordinal); + + Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE portfolio_game_reviews", migration, StringComparison.Ordinal); + Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal); + Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal); + Assert.Contains("CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))", migration, StringComparison.Ordinal); + Assert.Contains("CHECK (NOT is_public OR (public_slug IS NOT NULL AND description IS NOT NULL AND cover_storage_key IS NOT NULL AND published_at IS NOT NULL))", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE UNIQUE INDEX ux_portfolio_games_public_slug ON portfolio_games (lower(public_slug)) WHERE public_slug IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_games_public ON portfolio_games (completed_at DESC) WHERE is_public = true;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_author ON portfolio_game_reviews (author_player_id);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_public ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION lock_portfolio_publication_mutation() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_games_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation BEFORE DELETE OR UPDATE OF scheduled_at ON sessions FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON game_groups FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON players FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("IF final_scheduled_at >= now() THEN IF current_setting('transaction_isolation') <> 'read committed' THEN RAISE EXCEPTION 'portfolio future reschedule requires read committed isolation' USING ERRCODE = '0A000'; END IF; PERFORM pg.id", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM pg.id FROM portfolio_games pg WHERE EXISTS", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg; PERFORM pg_advisory_xact_lock(20260530, 108); UPDATE portfolio_games pg SET is_public = false", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("UPDATE portfolio_games pg SET is_public = false, updated_at = now() WHERE pg.is_public = true AND EXISTS (SELECT 1 FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id WHERE pgs.portfolio_game_id = pg.id AND s.scheduled_at >= now());", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("pg_advisory_xact_lock", normalizedMigration[unpublishFunctionStart..unpublishFunctionEnd], StringComparison.Ordinal); + Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); + Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); + } + + [Fact] + public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + + Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal); + Assert.DoesNotContain("s3_bucket", migration, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeSql(string sql) + { + return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Replace("( ", "(", StringComparison.Ordinal) + .Replace(" )", ")", StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs new file mode 100644 index 0000000..a9bc26b --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs @@ -0,0 +1,119 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioPagesTests +{ + [Fact] + public async Task PortfolioEditorPage_ShouldBeAuthorizedAndExposeEditorActions() + { + var editor = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PortfolioEditor.razor"); + + Assert.Contains("@page \"/portfolio/manage/{PortfolioGameId:guid}\"", editor, StringComparison.Ordinal); + Assert.Contains("@attribute [Authorize]", editor, StringComparison.Ordinal); + Assert.Contains("InputFile", editor, StringComparison.Ordinal); + Assert.Contains("ReplaceCoverForCurrentUserAsync", editor, StringComparison.Ordinal); + Assert.Contains("SetPublicationForCurrentUserAsync", editor, StringComparison.Ordinal); + Assert.Contains("ModerateReviewForCurrentUserAsync", editor, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupDetailsPage_ShouldExposePortfolioManagementActions() + { + var groupDetails = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor"); + + Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupCompletedSessionsPage_ShouldBeAuthorizedAndExposeCompletedSessions() + { + var completedSessions = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor"); + + Assert.Contains("@page \"/group/{GroupId:guid}/completed\"", completedSessions, StringComparison.Ordinal); + Assert.Contains("@attribute [Authorize]", completedSessions, StringComparison.Ordinal); + Assert.Contains("GetCompletedSessionsForCurrentUserAsync", completedSessions, StringComparison.Ordinal); + Assert.Contains("CreateDraftForCurrentUserAsync", completedSessions, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionHistoryPage_ShouldExposeQuickPortfolioActionForPastSessions() + { + var sessionHistory = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/SessionHistory.razor"); + + Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal); + Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal); + } + + [Fact] + public async Task AppCss_ShouldStylePortfolioManagementComponents() + { + var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css"); + + Assert.Contains(".portfolio-management-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-editor-grid", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-option-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-moderation", css, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicPortfolioPage_ShouldExposeSanitizedDetailAndReviewForm() + { + var publicPortfolio = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicPortfolio.razor"); + + Assert.Contains("@page \"/portfolio/{Slug}\"", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("@layout PublicLayout", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("@attribute [Authorize]", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGameBySlugAsync", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("SubmitReviewForCurrentUserAsync", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("publicationConsent", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicMasterProfilePage_ShouldIncludePortfolioCardGrid() + { + var publicMaster = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor"); + + Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPage_ShouldIncludePortfolioCardGrid() + { + var publicClub = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal); + } + + [Fact] + public async Task AppCss_ShouldStylePublicPortfolioComponents() + { + var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css"); + + Assert.Contains(".portfolio-grid", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-card", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-card-cover", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-cover-hero", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-card", css, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs new file mode 100644 index 0000000..7a593fa --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs @@ -0,0 +1,91 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioSchemaGateSourceTests +{ + [Fact] + public async Task Compose_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy() + { + var compose = await ReadRepositoryFileAsync("compose.yaml"); + + AssertServiceDependsOnHealthyBot(compose, "discord"); + AssertServiceDependsOnHealthyBot(compose, "web"); + } + + [Fact] + public async Task Aspire_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy() + { + var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); + + Assert.Contains( + "var bot = builder.AddProject(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\", isProxied: false) .WithHttpHealthCheck(\"/health\", endpointName: \"health\");", + appHost, + StringComparison.Ordinal); + Assert.Contains( + "builder.AddProject(\"discord\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);", + appHost, + StringComparison.Ordinal); + Assert.Contains( + "builder.AddProject(\"web\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);", + appHost, + StringComparison.Ordinal); + } + + [Fact] + public async Task Aspire_ShouldUseApplicationDatabaseConnectionStringName() + { + var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); + + Assert.Contains(".AddDatabase(\"gmrelaydb\");", appHost, StringComparison.Ordinal); + } + + private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName) + { + var serviceBlock = GetServiceBlock(compose, serviceName); + + Assert.Contains( + """ + bot: + condition: service_healthy + """, + serviceBlock, + StringComparison.Ordinal); + } + + private static string GetServiceBlock(string compose, string serviceName) + { + var lines = compose.Split('\n'); + var start = Array.FindIndex(lines, line => line.TrimEnd('\r') == $" {serviceName}:"); + Assert.True(start >= 0, $"compose.yaml should contain service '{serviceName}'."); + + var end = Array.FindIndex( + lines, + start + 1, + line => line.StartsWith(" ", StringComparison.Ordinal) + && !line.StartsWith(" ", StringComparison.Ordinal) + && line.TrimEnd('\r').EndsWith(':')); + + return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]); + } + + private static string NormalizeSource(string source) + { + return string.Join(' ', source.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs new file mode 100644 index 0000000..86121ce --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs @@ -0,0 +1,95 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioServiceSourceTests +{ + [Fact] + public async Task PortfolioService_ShouldExposePortfolioTablesAndPublicationGuards() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + + Assert.Contains("portfolio_games", source, StringComparison.Ordinal); + Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal); + Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal); + Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal); + Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal); + Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal); + Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal); + Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicMasterPortfolioQuery_ShouldNotRequirePublicSchedule() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + var publicMasterQuery = PublicMasterQuerySection(source); + + Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal); + Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPortfolioQuery_ShouldRequirePublicSchedule() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + var publicClubQuery = PublicClubQuerySection(source); + + Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal); + } + + [Fact] + public async Task ShowcaseSessionQuery_ShouldKeepFourHourFutureWindow() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); + var showcaseQuery = ShowcaseQuerySection(source); + + Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal); + } + + private static string PublicMasterQuerySection(string source) + { + var start = source.IndexOf("GetPublicPortfolioGamesForMasterAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetPublicPortfolioGamesForClubAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static string PublicClubQuerySection(string source) + { + var start = source.IndexOf("GetPublicPortfolioGamesForClubAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetPublicPortfolioGameBySlugAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static string ShowcaseQuerySection(string source) + { + var start = source.IndexOf("GetShowcaseSessionsAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetShowcaseSessionAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs new file mode 100644 index 0000000..f480e1d --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs @@ -0,0 +1,89 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioSessionDeletionSourceTests +{ + [Fact] + public async Task SharedDeleteSessionHandler_ShouldLockSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession() + { + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs")); + + const string sessionLock = + "FROM sessions s WHERE s.id = @SessionId FOR UPDATE OF s"; + const string advisoryLock = + "SELECT pg_advisory_xact_lock(20260530, 108)"; + const string unpublish = + "UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = @SessionId AND pg.is_public = true"; + + Assert.Contains(advisoryLock, source, StringComparison.Ordinal); + Assert.Contains(sessionLock, source, StringComparison.Ordinal); + Assert.Contains(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(advisoryLock, StringComparison.Ordinal) < + source.IndexOf(sessionLock, StringComparison.Ordinal), + "The shared delete path must acquire the portfolio advisory lock before locking the session."); + Assert.True( + source.IndexOf(sessionLock, StringComparison.Ordinal) < + source.IndexOf(unpublish, StringComparison.Ordinal), + "The shared delete path must lock the session before locking a linked portfolio card."); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal), + "Linked public portfolio cards must be unpublished before deleting the session."); + } + + [Fact] + public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession() + { + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs")); + + const string sessionLock = + "SELECT s.id FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId FOR UPDATE OF s"; + const string advisoryLock = + "SELECT pg_advisory_xact_lock(20260530, 108)"; + const string unpublish = + "UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true"; + + Assert.Contains(advisoryLock, source, StringComparison.Ordinal); + Assert.Contains(sessionLock, source, StringComparison.Ordinal); + Assert.Contains(unpublish, source, StringComparison.Ordinal); + Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(advisoryLock, StringComparison.Ordinal) < + source.IndexOf(sessionLock, StringComparison.Ordinal), + "The Discord delete path must acquire the portfolio advisory lock before locking the guild-scoped session."); + Assert.True( + source.IndexOf(sessionLock, StringComparison.Ordinal) < + source.IndexOf(unpublish, StringComparison.Ordinal), + "The Discord delete path must lock the guild-scoped session before locking a linked portfolio card."); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal), + "Discord cards must be unpublished before deleting the session."); + } + + private static string NormalizeSql(string sql) + { + return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Replace("( ", "(", StringComparison.Ordinal) + .Replace(" )", ")", StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs new file mode 100644 index 0000000..be26500 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs @@ -0,0 +1,103 @@ +using GmRelay.Web.Services.Portfolio; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioValidationTests +{ + [Theory] + [InlineData(" Dragon Heist ", "dragon-heist")] + [InlineData("dragon_heist", "dragon-heist")] + [InlineData("Dragon Heist", "dragon-heist")] + [InlineData("dragon---heist", "dragon-heist")] + [InlineData("dragon-heist-", "dragon-heist")] + [InlineData("DRAGON-Heist", "dragon-heist")] + public void NormalizeSlug_ShouldReturnCanonicalSlug(string input, string expected) + { + Assert.Equal(expected, PortfolioValidation.NormalizeSlug(input)); + } + + [Theory] + [InlineData("")] + [InlineData("ab")] + [InlineData("spaces are fine after normalization but this slug is intentionally far too long to be accepted because it exceeds the maximum portfolio slug size of one hundred and sixty characters")] + [InlineData("кириллица")] + [InlineData("hello world!")] + [InlineData("---")] + public void NormalizeSlug_ShouldRejectInvalidSlug(string input) + { + Assert.Throws(() => PortfolioValidation.NormalizeSlug(input)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void NormalizeReviewBody_ShouldRejectBlankText(string? body) + { + Assert.Throws(() => PortfolioValidation.NormalizeReviewBody(body)); + } + + [Theory] + [InlineData("This is a perfectly valid review body for the portfolio entry that should pass.")] + [InlineData(" Another valid review body that meets the minimum length requirement. ")] + public void NormalizeReviewBody_ShouldTrimAndAcceptValidText(string body) + { + Assert.Equal(body.Trim(), PortfolioValidation.NormalizeReviewBody(body)); + } + + [Theory] + [InlineData(" Hello World ")] + [InlineData("abc")] + public void NormalizeTitle_ShouldTrimAndAcceptValidText(string title) + { + Assert.Equal(title.Trim(), PortfolioValidation.NormalizeTitle(title)); + } + + [Theory] + [InlineData("")] + [InlineData("a")] + public void NormalizeTitle_ShouldRejectTooShort(string title) + { + Assert.Throws(() => PortfolioValidation.NormalizeTitle(title)); + } + + [Fact] + public void NormalizeDescription_ShouldReturnNullForWhitespace() + { + Assert.Null(PortfolioValidation.NormalizeDescription(null)); + Assert.Null(PortfolioValidation.NormalizeDescription("")); + Assert.Null(PortfolioValidation.NormalizeDescription(" ")); + } + + [Fact] + public void NormalizeDescription_ShouldTrimAndAcceptValidText() + { + Assert.Equal("hello", PortfolioValidation.NormalizeDescription(" hello ")); + } + + [Theory] + [InlineData("Online")] + [InlineData("Offline")] + [InlineData("Hybrid")] + public void NormalizeFormat_ShouldAcceptKnownValues(string format) + { + Assert.Equal(format, PortfolioValidation.NormalizeFormat(format)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void NormalizeFormat_ShouldReturnNullForBlank(string? format) + { + Assert.Null(PortfolioValidation.NormalizeFormat(format)); + } + + [Theory] + [InlineData("online")] + [InlineData("VTT")] + public void NormalizeFormat_ShouldRejectUnknownValues(string format) + { + Assert.Throws(() => PortfolioValidation.NormalizeFormat(format)); + } +} diff --git a/tests/GmRelay.Bot.Tests/packages.lock.json b/tests/GmRelay.Bot.Tests/packages.lock.json index d350362..385f11b 100644 --- a/tests/GmRelay.Bot.Tests/packages.lock.json +++ b/tests/GmRelay.Bot.Tests/packages.lock.json @@ -34,6 +34,15 @@ "resolved": "5.6.7", "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.12.0, )", + "resolved": "4.12.0", + "contentHash": "LZcQu4vfcYuzzy2ENOb7giFb6WNztEEJbufsm7kGiQxjallVuzkWxrBL8LwnjlXGW939pgEZFstL5cO0R2XrBQ==", + "dependencies": { + "Testcontainers": "4.12.0" + } + }, "xunit": { "type": "Direct", "requested": "[2.9.3, )", @@ -70,6 +79,11 @@ "Npgsql": "8.0.3" } }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, "Dapper": { "type": "Transitive", "resolved": "2.1.72", @@ -94,6 +108,63 @@ "dbup-core": "6.1.1" } }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "tm2V/DpnaURbBhMQ7Z3orNR3u+H863KQuYfA/sgGjI5py07dEeV0I02f6pGrx2869KG9uNM/E96puf9i0gId2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0", + "Docker.DotNet.Enhanced.LegacyHttp": "4.2.0", + "Docker.DotNet.Enhanced.NPipe": "4.2.0", + "Docker.DotNet.Enhanced.NativeHttp": "4.2.0", + "Docker.DotNet.Enhanced.Unix": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "cQNxpdadEPdNdfjFCl9vgoCQIK3aVHRn1Qlu36aZUFpp4xHfPrk4hRPNVLR/CpobIFJ+dAt8AceTKMlCfPSccw==" + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "sfbMX1HBPUec3PEMoqlP5ak6skXclcTBmu4gG3aUJatP34J2DgvYMP13bvz/rfrjVkAhPqnIiDKiHAkBCokajg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "/ll+2ePYm1qrsMdgMO5BzCQnbfTGmPJAc9SqXEManbliVBZvEpBKHXLugx/OeEca2oC/b4RV+UNPtue5u4jAuA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "8wyYOD6VkvqRkITwsvkt3UbW/1WDl6NFypNAsIIDaMiglNRzFrQcK0nK9VUEZa6Oja8Bso3UYySDoL8qatatAA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "x0wNcbww1+p9nUfw8i+JvsSArBDGkoZ9GI2PZ1wPo85B2OiFrdzp89omounNhO2GKyaIRWAqAm5jYZyNg9EnxA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "nMw+FHGwGZieDi7kBgpIVl+E8MzjzXeXHvMQpidLADT06fts2Gw6G+K+p0hMGv7liZULxyYiZnQ1UbE2B9NNQg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, "Microsoft.AspNetCore.TestHost": { "type": "Transitive", "resolved": "10.0.5", @@ -341,11 +412,35 @@ "Polly.Core": "8.4.2" } }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2" + } + }, "Telegram.Bot": { "type": "Transitive", "resolved": "22.9.6.1", "contentHash": "I0eaMaETcWIhMn4uu4RGd9e6PLJOjaOG3QAcKPsTcS80H3TF6gqj3UF9NKu4ZY90ul6Y6NiWToHkg/PsvxkotA==" }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.12.0", + "contentHash": "PTZRdG1ZVkFMsFbc3cK/VUaOB5L3l4wYL+OkWAK33/cvgd/5FcmZlQ6NhMAl3PWBqYkpdWmeYmQW9U2OIXqtFA==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.2.0", + "Docker.DotNet.Enhanced.X509": "4.2.0", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -392,8 +487,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.5.3, )", "dbup-postgresql": "[7.0.1, )" @@ -405,8 +500,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "NetCord.Hosting": "[1.0.0-alpha.489, )", "NetCord.Hosting.Services": "[1.0.0-alpha.489, )", "NetCord.Services": "[1.0.0-alpha.489, )", @@ -437,8 +532,8 @@ "dependencies": { "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.6.1, )" }