From ac417731d64ef8800f024fd0f9ee7dd1dd7683a8 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 21:36:05 +0300 Subject: [PATCH] docs: plan completed game portfolio implementation --- .../2026-05-30-completed-game-portfolio.md | 1208 +++++++++++++++++ ...6-05-30-completed-game-portfolio-design.md | 18 +- 2 files changed, 1218 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-30-completed-game-portfolio.md 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..d682780 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -0,0 +1,1208 @@ +# 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/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.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 + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` +- Create: `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql` + +- [ ] **Step 1: Write the failing migration source-contract test** + +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"); + + 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); +} +``` + +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); +} +``` + +- [ ] **Step 2: Run the migration test to verify RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests" +``` + +Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist. + +- [ ] **Step 3: 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 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_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 (created_at) + WHERE moderation_status = 'Pending'; +``` + +- [ ] **Step 4: Run the migration tests to verify GREEN** + +Run the Task 1 command again. Expected: PASS. + +- [ ] **Step 5: Commit** + +```powershell +git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +git commit -m "feat(data): add completed game portfolio schema" +``` + +--- + +### 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, 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 past sessions, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. +- 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 index a18db71..162adb4 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -15,7 +15,7 @@ Add a public portfolio of completed tabletop adventures. A club owner or co-GM c - 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 registered for at least one linked completed session. +- 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. @@ -30,9 +30,9 @@ Add a public portfolio of completed tabletop adventures. A club owner or co-GM c ## Architecture -Add a 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. +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. -The protected management flow is exposed through `AuthorizedSessionService`, which reuses the existing owner/co-GM group authorization model. Public reads and authenticated review submission are exposed through `ISessionStore` and `SessionService`. +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. @@ -173,7 +173,7 @@ The storage key remains provider-neutral. A future S3-compatible implementation ## Service Contracts -Add sanitized DTOs to `ISessionStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links. +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: @@ -210,7 +210,7 @@ Protected DTOs may carry IDs needed for editing and moderation. ### Protected Management -Through `AuthorizedSessionService`: +Through `AuthorizedPortfolioService`: - Load draft and published adventure cards for a managed club. - Load eligible completed sessions for a managed club. @@ -229,7 +229,7 @@ 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` for at least one linked session. +- 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. @@ -245,11 +245,11 @@ 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 and edit links. +- Provide a create action, edit links, and a link to the club's completed-session list. ### Completed Session Quick Action -Extend session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected. +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 @@ -320,6 +320,8 @@ volumes: 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. + --- ## Documentation