feat(web): add completed-game portfolio to GM showcase (issue #108) #118
Reference in New Issue
Block a user
Delete Branch "codex/feature-issue-108-portfolio"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
portfolio_games,portfolio_game_sessions,portfolio_game_masters,portfolio_game_reviewstables; 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.IPortfolioStore(16 methods), sanitized public DTOs (PublicPortfolioCard,PublicPortfolioGame,PublicPortfolioMaster,PublicPortfolioReview) carrying no private UUIDs, storage keys, or platform info;PortfolioValidationfor slug/title/description/format/review body.IPortfolioCoverStoragewithLocalPortfolioCoverStorage— JPEG/PNG/WebP signature validation (not just content-type), 5 MB cap,SafeKeyPatternpath safety, temp-file cleanup on failure;portfolio_coversvolume mount andPORTFOLIO_COVERS_VOLUME_NAMEenv var.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 flippingis_public = true, slug-uniqueness friendly error mapping, cover swap that returns the prior key.AuthorizedPortfolioService— every protected method goes throughIsGroupManagerAsync; cross-club attempts raiseSessionAccessDeniedException; review submission requirespublicationConsent = true; cover replacement saves-new → DB-swap → delete-old with new-cover cleanup on failure; delete removes DB row first, then cover./portfolio/manage/{PortfolioGameId:guid}editor,/group/{GroupId:guid}/completedlisting,Добавить в портфолиоquick action on past session history, group management section with draft/public badges and pending-review counts./portfolio/{slug}detail page usingPublicLayout(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.Directory.Build.props,compose.yaml(3 images),.gitea/workflows/deploy.yml,NavMenu.razor,README.md; new portfolio section inREADME.md(route, moderated reviews, cover volume, env var); new portfolio section indocs/c4-system-context.md(tables, services, S3 replacement boundary).Acceptance criteria (from #108)
publicationConsent) or moderation (Approvedonly).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.3.6.0; no stray3.5.1in any release file.s.scheduled_at > now() - interval '4 hours') preserved unchanged.AuthorizedPortfolioServiceroutes throughIsGroupManagerAsync.Test coverage
PortfolioContractsTests,PortfolioValidationTests(Task 2)LocalPortfolioCoverStorageTests,PortfolioCoverRuntimeWiringTests(Task 3)PortfolioServiceSourceTests(Task 4) — includes SQL fragment assertions and a regression assertion on the unchangedinterval '4 hours'showcase queryAuthorizedPortfolioServiceTests(Task 5) — manager checks, cross-club rejection, cover-swap ordering, delete ordering, anonymous review state, slug normalizationPortfolioPagesTests(Tasks 6 + 7) — public/protected page route + symbol assertionsNotes
PortfolioSchemaGateSourceTests.Compose_…CRLF test failure: not introduced by this branch. Worth a separate cleanup (normalize line endings incompose.yamlor in the test) but out of scope here.GetPortfolioMasterOptionsAsyncis implemented inPortfolioServicebut not called byAuthorizedPortfolioService; left in place for a future "add co-GM" flow (NIT, not blocking).🤖 Generated with Claude Code
Само-ревью после двухстадийной проверки (spec compliance + code quality):
Data layer (V029)
validate_public_portfolio_game_required_links— deferred constraint trigger покрывает INSERT наportfolio_gamesи DELETE на дочерних таблицахpg_advisory_xact_lock(20260530, 108)сериализует все мутации в правильном порядкеWHERE pg.group_id = @GroupIdна всех scoped-чтениях/записяхContracts
IPortfolioStore— 16 методов, чистые границыPortfolioValidation— slug regex, длина, trimming; review body 10..2000Cover storage
SafeKeyPatternAuthorization (
AuthorizedPortfolioService)RequireManagerForGameAsync→IsGroupManagerAsyncGetReviewSubmissionStateкорректно резолвит linked player identity в обе стороныpublicationConsent = falseотклоняетсяUI
/portfolio/manage/{id}(Authorize) — editor с cover upload/portfolio/{slug}(PublicLayout) — санитизированный DTO/group/{id}/completed— список прошедшихДобавить в портфолиоquick action на past session historyapp.cssVerified
dotnet build— 0/0dotnet test— 468/469 (1 pre-existing CRLF, не из этой работы)dotnet format --verify-no-changes— exit 0interval '4 hours'не тронутPortfolioSchemaGateSourceTests.Compose_…подтверждён как environmental (CRLF), не из этой работыNITs (не блокирующие)
GetPortfolioMasterOptionsAsyncвPortfolioServiceне используетсяAuthorizedPortfolioService— dead code, можно удалить или оставить на будущееsessionStoreупомянут вRel(...)без явногоComponent()declaration — рендерится, но не идеальноГотово к мёрджу (после approve от второго ревьювера, что Gitea требует для branch protection).
Second-opinion code review of PR #118 (issue #108) by a fresh reviewer subagent. Verdict: APPROVED — no blockers.
Verified (all 6 focus areas)
PublicPortfolioCard/Game/Master/Reviewcarry 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.pg.group_id = @GroupIdfilter;AuthorizedPortfolioServiceroutes all calls throughRequireManagerForGameAsync/RequireManagerAsync→IsGroupManagerAsync.SafeKeyPattern(^[a-f0-9]{32}\\.(jpg|png|webp)$) prevents traversal; signature validation (JPEGFF D8 FF, PNG89 50 4E 47 0D 0A 1A 0A, WebPRIFF+WEBP); 5 MB cap; correct swap ordering (new → DB → old; new cleanup on throw) and delete ordering (row → cover).publicationConsent = trueserver-enforced; eligibility requires non-GM Active participant; duplicates blocked byUNIQUE (portfolio_game_id, author_player_id)+ON CONFLICT DO NOTHING; moderation scoped bypg.group_id = @GroupIdjoin.SessionService.csnot modified; all 7 occurrences ofs.scheduled_at > now() - interval '4 hours'preserved.CampaignTemplatesNavigationTestsasserts"v3.6.0".validate_public_portfolio_game_required_linkscorrectly rejectsREPEATABLE READ/SERIALIZABLEto prevent stale-snapshot validation.NIT-level findings (out of scope for this PR, for follow-up)
RELEASE_NOTES.md:1-23andPR_DESCRIPTION.md:1-22still 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—UpdatePortfolioDraftAsyncdoesn't pre-check that linked sessions aren't already in another portfolio. Caught byUNIQUE (session_id)at INSERT, but the resultingPostgresException(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—Formatis a free-textInputText; should be a<select>with the three allowed values (Online/Offline/Hybrid) perNormalizeFormat.completed_atis not editable in the management UI (set tonow()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.