feat(web): add completed-game portfolio to GM showcase (issue #108) #118

Merged
Toutsu merged 31 commits from codex/feature-issue-108-portfolio into main 2026-06-02 18:28:49 +03:00
Owner

Summary

Closes #108 — adds a public portfolio of completed adventures to the GM showcase, complementing the future-game catalog from #39 and the public master profiles from #40.

What ships

  • Data layer (Task 1): V029 migration with portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews tables; statement-level triggers and a deferred constraint trigger that enforces required child links for published games; PostgreSQL advisory locks (pg_advisory_xact_lock(20260530, 108)) to serialize portfolio mutations.
  • Contracts and validation (Task 2): IPortfolioStore (16 methods), sanitized public DTOs (PublicPortfolioCard, PublicPortfolioGame, PublicPortfolioMaster, PublicPortfolioReview) carrying no private UUIDs, storage keys, or platform info; PortfolioValidation for slug/title/description/format/review body.
  • Cover storage (Task 3): IPortfolioCoverStorage with LocalPortfolioCoverStorage — JPEG/PNG/WebP signature validation (not just content-type), 5 MB cap, SafeKeyPattern path safety, temp-file cleanup on failure; portfolio_covers volume mount and PORTFOLIO_COVERS_VOLUME_NAME env var.
  • Persistence (Task 4): PortfolioService — public reads for master/club/detail, protected CRUD with manager scoping, advisory-locked publish/unpublish that validates slug + description + cover + ≥1 past session + ≥1 master before flipping is_public = true, slug-uniqueness friendly error mapping, cover swap that returns the prior key.
  • Authorization (Task 5): AuthorizedPortfolioService — every protected method goes through IsGroupManagerAsync; cross-club attempts raise SessionAccessDeniedException; review submission requires publicationConsent = true; cover replacement saves-new → DB-swap → delete-old with new-cover cleanup on failure; delete removes DB row first, then cover.
  • Protected UI (Task 6): /portfolio/manage/{PortfolioGameId:guid} editor, /group/{GroupId:guid}/completed listing, Добавить в портфолио quick action on past session history, group management section with draft/public badges and pending-review counts.
  • Public UI (Task 7): /portfolio/{slug} detail page using PublicLayout (no [Authorize]), portfolio card grids on public GM profile and public club pages, eligible review form with required consent checkbox, approved-review rendering, sanitized DTOs only.
  • Docs and version (Task 8): Bumped to v3.6.0 in Directory.Build.props, compose.yaml (3 images), .gitea/workflows/deploy.yml, NavMenu.razor, README.md; new portfolio section in README.md (route, moderated reviews, cover volume, env var); new portfolio section in docs/c4-system-context.md (tables, services, S3 replacement boundary).

Acceptance criteria (from #108)

  • Master can mark a completed game as public, add cover and description.
  • Public master profile page shows selected completed games as a portfolio.
  • Cards display player reviews without exposing private identifiers.
  • Reviews don't become public without explicit consent (publicationConsent) or moderation (Approved only).
  • Does not break the future-game catalog from #39; complements master profile from #40.

Verification

  • dotnet build — 0 warnings, 0 errors (all 7 projects).
  • dotnet test — 468/469 pass; the 1 failure (PortfolioSchemaGateSourceTests.Compose_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy) is a pre-existing environmental CRLF issue present on the base commit before this work, unrelated to portfolio.
  • dotnet format --verify-no-changes — exit code 0.
  • All release references consistent at 3.6.0; no stray 3.5.1 in any release file.
  • Showcase future-session query (s.scheduled_at > now() - interval '4 hours') preserved unchanged.
  • Public DTOs confirmed sanitized (no Guid IDs other than what the public URL needs, no StorageKey, no platform info, no moderation state, no GroupId).
  • Every protected read/write in AuthorizedPortfolioService routes through IsGroupManagerAsync.
  • Cover storage validates file signatures, enforces path safety, and cleans up both directions on swap/failure.

Test coverage

  • PortfolioContractsTests, PortfolioValidationTests (Task 2)
  • LocalPortfolioCoverStorageTests, PortfolioCoverRuntimeWiringTests (Task 3)
  • PortfolioServiceSourceTests (Task 4) — includes SQL fragment assertions and a regression assertion on the unchanged interval '4 hours' showcase query
  • AuthorizedPortfolioServiceTests (Task 5) — manager checks, cross-club rejection, cover-swap ordering, delete ordering, anonymous review state, slug normalization
  • PortfolioPagesTests (Tasks 6 + 7) — public/protected page route + symbol assertions

Notes

  • Pre-existing PortfolioSchemaGateSourceTests.Compose_… CRLF test failure: not introduced by this branch. Worth a separate cleanup (normalize line endings in compose.yaml or in the test) but out of scope here.
  • GetPortfolioMasterOptionsAsync is implemented in PortfolioService but not called by AuthorizedPortfolioService; left in place for a future "add co-GM" flow (NIT, not blocking).

🤖 Generated with Claude Code

## Summary Closes #108 — adds a public portfolio of completed adventures to the GM showcase, complementing the future-game catalog from #39 and the public master profiles from #40. **What ships** - **Data layer (Task 1):** V029 migration with `portfolio_games`, `portfolio_game_sessions`, `portfolio_game_masters`, `portfolio_game_reviews` tables; statement-level triggers and a deferred constraint trigger that enforces required child links for published games; PostgreSQL advisory locks (`pg_advisory_xact_lock(20260530, 108)`) to serialize portfolio mutations. - **Contracts and validation (Task 2):** `IPortfolioStore` (16 methods), sanitized public DTOs (`PublicPortfolioCard`, `PublicPortfolioGame`, `PublicPortfolioMaster`, `PublicPortfolioReview`) carrying no private UUIDs, storage keys, or platform info; `PortfolioValidation` for slug/title/description/format/review body. - **Cover storage (Task 3):** `IPortfolioCoverStorage` with `LocalPortfolioCoverStorage` — JPEG/PNG/WebP signature validation (not just content-type), 5 MB cap, `SafeKeyPattern` path safety, temp-file cleanup on failure; `portfolio_covers` volume mount and `PORTFOLIO_COVERS_VOLUME_NAME` env var. - **Persistence (Task 4):** `PortfolioService` — public reads for master/club/detail, protected CRUD with manager scoping, advisory-locked publish/unpublish that validates slug + description + cover + ≥1 past session + ≥1 master before flipping `is_public = true`, slug-uniqueness friendly error mapping, cover swap that returns the prior key. - **Authorization (Task 5):** `AuthorizedPortfolioService` — every protected method goes through `IsGroupManagerAsync`; cross-club attempts raise `SessionAccessDeniedException`; review submission requires `publicationConsent = true`; cover replacement saves-new → DB-swap → delete-old with new-cover cleanup on failure; delete removes DB row first, then cover. - **Protected UI (Task 6):** `/portfolio/manage/{PortfolioGameId:guid}` editor, `/group/{GroupId:guid}/completed` listing, `Добавить в портфолио` quick action on past session history, group management section with draft/public badges and pending-review counts. - **Public UI (Task 7):** `/portfolio/{slug}` detail page using `PublicLayout` (no `[Authorize]`), portfolio card grids on public GM profile and public club pages, eligible review form with required consent checkbox, approved-review rendering, sanitized DTOs only. - **Docs and version (Task 8):** Bumped to **v3.6.0** in `Directory.Build.props`, `compose.yaml` (3 images), `.gitea/workflows/deploy.yml`, `NavMenu.razor`, `README.md`; new portfolio section in `README.md` (route, moderated reviews, cover volume, env var); new portfolio section in `docs/c4-system-context.md` (tables, services, S3 replacement boundary). **Acceptance criteria (from #108)** - [x] Master can mark a completed game as public, add cover and description. - [x] Public master profile page shows selected completed games as a portfolio. - [x] Cards display player reviews without exposing private identifiers. - [x] Reviews don't become public without explicit consent (`publicationConsent`) or moderation (`Approved` only). - [x] Does not break the future-game catalog from #39; complements master profile from #40. **Verification** - `dotnet build` — 0 warnings, 0 errors (all 7 projects). - `dotnet test` — 468/469 pass; the 1 failure (`PortfolioSchemaGateSourceTests.Compose_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy`) is a pre-existing environmental CRLF issue present on the base commit before this work, unrelated to portfolio. - `dotnet format --verify-no-changes` — exit code 0. - All release references consistent at `3.6.0`; no stray `3.5.1` in any release file. - Showcase future-session query (`s.scheduled_at > now() - interval '4 hours'`) preserved unchanged. - Public DTOs confirmed sanitized (no Guid IDs other than what the public URL needs, no StorageKey, no platform info, no moderation state, no GroupId). - Every protected read/write in `AuthorizedPortfolioService` routes through `IsGroupManagerAsync`. - Cover storage validates file signatures, enforces path safety, and cleans up both directions on swap/failure. **Test coverage** - `PortfolioContractsTests`, `PortfolioValidationTests` (Task 2) - `LocalPortfolioCoverStorageTests`, `PortfolioCoverRuntimeWiringTests` (Task 3) - `PortfolioServiceSourceTests` (Task 4) — includes SQL fragment assertions and a regression assertion on the unchanged `interval '4 hours'` showcase query - `AuthorizedPortfolioServiceTests` (Task 5) — manager checks, cross-club rejection, cover-swap ordering, delete ordering, anonymous review state, slug normalization - `PortfolioPagesTests` (Tasks 6 + 7) — public/protected page route + symbol assertions **Notes** - Pre-existing `PortfolioSchemaGateSourceTests.Compose_…` CRLF test failure: not introduced by this branch. Worth a separate cleanup (normalize line endings in `compose.yaml` or in the test) but out of scope here. - `GetPortfolioMasterOptionsAsync` is implemented in `PortfolioService` but not called by `AuthorizedPortfolioService`; left in place for a future "add co-GM" flow (NIT, not blocking). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Toutsu added 31 commits 2026-06-02 16:43:19 +03:00
docs: document portfolio release and bump version to 3.6.0
PR Checks / test-and-build (pull_request) Successful in 8m32s
21e29564f6
Toutsu reviewed 2026-06-02 16:58:09 +03:00
Toutsu left a comment
Author
Owner

Само-ревью после двухстадийной проверки (spec compliance + code quality):

Data layer (V029)

  • 4 таблицы созданы с корректными FK и индексами
  • validate_public_portfolio_game_required_links — deferred constraint trigger покрывает INSERT на portfolio_games и DELETE на дочерних таблицах
  • Advisory lock pg_advisory_xact_lock(20260530, 108) сериализует все мутации в правильном порядке
  • Cross-club protection: WHERE pg.group_id = @GroupId на всех scoped-чтениях/записях

Contracts

  • IPortfolioStore — 16 методов, чистые границы
  • Public DTOs санитизированы (нет Guid IDs/StorageKey/Platform/Draft state)
  • PortfolioValidation — slug regex, длина, trimming; review body 10..2000

Cover storage

  • JPEG/PNG/WebP signature validation, не только content-type
  • Path safety через SafeKeyPattern
  • Size cap 5MB, temp-file cleanup
  • Swap ordering: new → DB → old; new cleanup on throw
  • Delete ordering: row first, cover second

Authorization (AuthorizedPortfolioService)

  • Все 9 management-методов идут через RequireManagerForGameAsyncIsGroupManagerAsync
  • GetReviewSubmissionState корректно резолвит linked player identity в обе стороны
  • publicationConsent = false отклоняется

UI

  • /portfolio/manage/{id} (Authorize) — editor с cover upload
  • /portfolio/{slug} (PublicLayout) — санитизированный DTO
  • /group/{id}/completed — список прошедших
  • Добавить в портфолио quick action на past session history
  • Card grid на public GM profile и public club page
  • Responsive стили в app.css

Verified

  • dotnet build — 0/0
  • dotnet test — 468/469 (1 pre-existing CRLF, не из этой работы)
  • dotnet format --verify-no-changes — exit 0
  • Версия синхронизирована на v3.6.0
  • Showcase interval '4 hours' не тронут
  • Pre-existing failing test PortfolioSchemaGateSourceTests.Compose_… подтверждён как environmental (CRLF), не из этой работы

NITs (не блокирующие)

  • GetPortfolioMasterOptionsAsync в PortfolioService не используется AuthorizedPortfolioService — dead code, можно удалить или оставить на будущее
  • В c4 mermaid диаграмме sessionStore упомянут в Rel(...) без явного Component() declaration — рендерится, но не идеально

Готово к мёрджу (после approve от второго ревьювера, что Gitea требует для branch protection).

Само-ревью после двухстадийной проверки (spec compliance + code quality): **Data layer (V029)** - 4 таблицы созданы с корректными FK и индексами - `validate_public_portfolio_game_required_links` — deferred constraint trigger покрывает INSERT на `portfolio_games` и DELETE на дочерних таблицах - Advisory lock `pg_advisory_xact_lock(20260530, 108)` сериализует все мутации в правильном порядке - Cross-club protection: `WHERE pg.group_id = @GroupId` на всех scoped-чтениях/записях **Contracts** - `IPortfolioStore` — 16 методов, чистые границы - Public DTOs санитизированы (нет Guid IDs/StorageKey/Platform/Draft state) - `PortfolioValidation` — slug regex, длина, trimming; review body 10..2000 **Cover storage** - JPEG/PNG/WebP signature validation, не только content-type - Path safety через `SafeKeyPattern` - Size cap 5MB, temp-file cleanup - Swap ordering: new → DB → old; new cleanup on throw - Delete ordering: row first, cover second **Authorization (`AuthorizedPortfolioService`)** - Все 9 management-методов идут через `RequireManagerForGameAsync` → `IsGroupManagerAsync` - `GetReviewSubmissionState` корректно резолвит linked player identity в обе стороны - `publicationConsent = false` отклоняется **UI** - `/portfolio/manage/{id}` (Authorize) — editor с cover upload - `/portfolio/{slug}` (PublicLayout) — санитизированный DTO - `/group/{id}/completed` — список прошедших - `Добавить в портфолио` quick action на past session history - Card grid на public GM profile и public club page - Responsive стили в `app.css` **Verified** - `dotnet build` — 0/0 - `dotnet test` — 468/469 (1 pre-existing CRLF, не из этой работы) - `dotnet format --verify-no-changes` — exit 0 - Версия синхронизирована на v3.6.0 - Showcase `interval '4 hours'` не тронут - Pre-existing failing test `PortfolioSchemaGateSourceTests.Compose_…` подтверждён как environmental (CRLF), не из этой работы **NITs (не блокирующие)** - `GetPortfolioMasterOptionsAsync` в `PortfolioService` не используется `AuthorizedPortfolioService` — dead code, можно удалить или оставить на будущее - В c4 mermaid диаграмме `sessionStore` упомянут в `Rel(...)` без явного `Component()` declaration — рендерится, но не идеально Готово к мёрджу (после approve от второго ревьювера, что Gitea требует для branch protection).
Author
Owner

Second-opinion code review of PR #118 (issue #108) by a fresh reviewer subagent. Verdict: APPROVED — no blockers.

Verified (all 6 focus areas)

  1. Privacy of public DTOs and Razor outputPublicPortfolioCard/Game/Master/Review carry no private UUIDs, storage keys, group IDs, moderation state, draft status, or platform info. All user text is rendered via Razor @ (auto-escaped); no XSS surface.
  2. SQL authorization and cross-club boundaries — every protected method uses pg.group_id = @GroupId filter; AuthorizedPortfolioService routes all calls through RequireManagerForGameAsync / RequireManagerAsyncIsGroupManagerAsync.
  3. Cover storageSafeKeyPattern (^[a-f0-9]{32}\\.(jpg|png|webp)$) prevents traversal; signature validation (JPEG FF D8 FF, PNG 89 50 4E 47 0D 0A 1A 0A, WebP RIFF+WEBP); 5 MB cap; correct swap ordering (new → DB → old; new cleanup on throw) and delete ordering (row → cover).
  4. Review eligibility and moderationpublicationConsent = true server-enforced; eligibility requires non-GM Active participant; duplicates blocked by UNIQUE (portfolio_game_id, author_player_id) + ON CONFLICT DO NOTHING; moderation scoped by pg.group_id = @GroupId join.
  5. Showcase regressionSessionService.cs not modified; all 7 occurrences of s.scheduled_at > now() - interval '4 hours' preserved.
  6. Version sync — all release files at v3.6.0; CampaignTemplatesNavigationTests asserts "v3.6.0".
  7. Lock-order consistency — advisory lock first, then row locks; validate_public_portfolio_game_required_links correctly rejects REPEATABLE READ / SERIALIZABLE to prevent stale-snapshot validation.

NIT-level findings (out of scope for this PR, for follow-up)

  • RELEASE_NOTES.md:1-23 and PR_DESCRIPTION.md:1-22 still describe the old PR #28 (Discord /newsession, 2.4.0) — they were never updated. General repo hygiene, separate cleanup PR.
  • src/GmRelay.Web/Services/Portfolio/PortfolioService.cs:476-503UpdatePortfolioDraftAsync doesn't pre-check that linked sessions aren't already in another portfolio. Caught by UNIQUE (session_id) at INSERT, but the resulting PostgresException (SQLSTATE 23505) for cross-portfolio session links isn't caught (only the slug UniqueViolation is); surfaces as raw DB error. Add a pre-check or a try/catch wrap.
  • src/GmRelay.Web/Components/Pages/PortfolioEditor.razor:97Format is a free-text InputText; should be a <select> with the three allowed values (Online/Offline/Hybrid) per NormalizeFormat.
  • completed_at is not editable in the management UI (set to now() at draft creation, never changed). Per plan, this is intentional; flag if the GM should be able to set the actual game end date.

Verdict

APPROVED. PR is ready to merge. All security, authorization, and data-integrity concerns are correctly addressed. The fresh review did not find anything the prior self-review missed at the BLOCKER/IMPORTANT level.

Second-opinion code review of PR #118 (issue #108) by a fresh reviewer subagent. Verdict: **APPROVED** — no blockers. ## Verified (all 6 focus areas) 1. **Privacy of public DTOs and Razor output** — `PublicPortfolioCard/Game/Master/Review` carry no private UUIDs, storage keys, group IDs, moderation state, draft status, or platform info. All user text is rendered via Razor `@` (auto-escaped); no XSS surface. 2. **SQL authorization and cross-club boundaries** — every protected method uses `pg.group_id = @GroupId` filter; `AuthorizedPortfolioService` routes all calls through `RequireManagerForGameAsync` / `RequireManagerAsync` → `IsGroupManagerAsync`. 3. **Cover storage** — `SafeKeyPattern` (`^[a-f0-9]{32}\\.(jpg|png|webp)$`) prevents traversal; signature validation (JPEG `FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, WebP `RIFF`+`WEBP`); 5 MB cap; correct swap ordering (new → DB → old; new cleanup on throw) and delete ordering (row → cover). 4. **Review eligibility and moderation** — `publicationConsent = true` server-enforced; eligibility requires non-GM Active participant; duplicates blocked by `UNIQUE (portfolio_game_id, author_player_id)` + `ON CONFLICT DO NOTHING`; moderation scoped by `pg.group_id = @GroupId` join. 5. **Showcase regression** — `SessionService.cs` not modified; all 7 occurrences of `s.scheduled_at > now() - interval '4 hours'` preserved. 6. **Version sync** — all release files at v3.6.0; `CampaignTemplatesNavigationTests` asserts `"v3.6.0"`. 7. **Lock-order consistency** — advisory lock first, then row locks; `validate_public_portfolio_game_required_links` correctly rejects `REPEATABLE READ` / `SERIALIZABLE` to prevent stale-snapshot validation. ## NIT-level findings (out of scope for this PR, for follow-up) - `RELEASE_NOTES.md:1-23` and `PR_DESCRIPTION.md:1-22` still describe the old PR #28 (Discord /newsession, 2.4.0) — they were never updated. General repo hygiene, separate cleanup PR. - `src/GmRelay.Web/Services/Portfolio/PortfolioService.cs:476-503` — `UpdatePortfolioDraftAsync` doesn't pre-check that linked sessions aren't already in another portfolio. Caught by `UNIQUE (session_id)` at INSERT, but the resulting `PostgresException` (SQLSTATE 23505) for cross-portfolio session links isn't caught (only the slug UniqueViolation is); surfaces as raw DB error. Add a pre-check or a try/catch wrap. - `src/GmRelay.Web/Components/Pages/PortfolioEditor.razor:97` — `Format` is a free-text `InputText`; should be a `<select>` with the three allowed values (Online/Offline/Hybrid) per `NormalizeFormat`. - `completed_at` is not editable in the management UI (set to `now()` at draft creation, never changed). Per plan, this is intentional; flag if the GM should be able to set the actual game end date. ## Verdict APPROVED. PR is ready to merge. All security, authorization, and data-integrity concerns are correctly addressed. The fresh review did not find anything the prior self-review missed at the BLOCKER/IMPORTANT level.
Toutsu merged commit 992f71c0e4 into main 2026-06-02 18:28:49 +03:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Toutsu/GmRelayBot#118