Files
GmRelayBot/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md
T

1650 lines
73 KiB
Markdown

# 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
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
```
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<InvalidOperationException>(() => PortfolioValidation.NormalizeSlug(input));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void NormalizeReviewBody_ShouldRejectBlankText(string body)
{
Assert.Throws<InvalidOperationException>(() => 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<PublicPortfolioMaster> Masters,
IReadOnlyList<PublicPortfolioReview> 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<PortfolioSessionOption> Sessions,
IReadOnlyList<PortfolioMasterOption> Masters,
IReadOnlyList<PortfolioReviewForModeration> Reviews);
public sealed record PortfolioGameUpdate(
string Title, string? PublicSlug, string? Description, string? System, string? Format,
IReadOnlyCollection<Guid> SessionIds, IReadOnlyCollection<Guid> MasterPlayerIds);
public enum PortfolioReviewSubmissionState
{
RequiresAuthentication,
Ineligible,
Eligible,
AlreadySubmitted
}
```
Define `IPortfolioStore` with:
```csharp
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
```
- [ ] **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<InvalidOperationException>(
() => 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<PortfolioCoverUploadResult> 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<IPortfolioStore, PortfolioService>();
```
- [ ] **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<SessionAccessDeniedException>(
() => 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<InvalidOperationException>(
() => 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<AuthorizedPortfolioService>();
```
- [ ] **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<PublicPortfolioCard> 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`: `<Version>3.6.0</Version>`
- `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`.