66 KiB
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.sqlsrc/GmRelay.Web/Services/Portfolio/IPortfolioStore.cssrc/GmRelay.Web/Services/Portfolio/PortfolioContracts.cssrc/GmRelay.Web/Services/Portfolio/PortfolioValidation.cssrc/GmRelay.Web/Services/Portfolio/PortfolioService.cssrc/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cssrc/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cssrc/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cssrc/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cssrc/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cssrc/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razorsrc/GmRelay.Web/Components/Pages/GroupCompletedSessions.razorsrc/GmRelay.Web/Components/Pages/PortfolioEditor.razorsrc/GmRelay.Web/Components/Pages/PublicPortfolio.razortests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cstests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cstests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cstests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cstests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cstests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cstests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cstests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cstests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cstests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cstests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cstests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs
Modify
src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cssrc/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cssrc/GmRelay.AppHost/Program.cstests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csprojtests/GmRelay.Bot.Tests/packages.lock.jsonsrc/GmRelay.Web/Program.cssrc/GmRelay.Web/appsettings.Development.jsonsrc/GmRelay.Web/Dockerfilesrc/GmRelay.Web/Components/Pages/GroupDetails.razorsrc/GmRelay.Web/Components/Pages/SessionHistory.razorsrc/GmRelay.Web/Components/Pages/PublicMasterProfile.razorsrc/GmRelay.Web/Components/Pages/PublicClub.razorsrc/GmRelay.Web/wwwroot/app.css.env.examplecompose.yamlREADME.mddocs/c4-system-context.mdDirectory.Build.props.gitea/workflows/deploy.ymlsrc/GmRelay.Web/Components/Layout/NavMenu.razor
Task 1: Add Portfolio Schema
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:
[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 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("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);
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal);
}
Add a second test asserting the public-card columns are provider-neutral:
[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 explicitly lock the target session row, unpublish linked cards, and then delete the required session link:
[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 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(sessionLock, source, StringComparison.Ordinal);
Assert.Contains(unpublish, 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 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: save the bot project resource to a variable, expose its named port 8081 HTTP endpoint, 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.
- Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests
Add the package reference:
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
Update the locked dependency graph:
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:
[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_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst)
[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 PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
[Fact]
public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard()
[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 future reschedule must atomically unpublish a linked public card while preserving its first published_at. 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 sessions then portfolio_games lock order, cover both first-session-lock orders through real blocking transactions, and finish with the card private and session deleted. The publish/reschedule race 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, 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:
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:
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 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 $$
BEGIN
IF OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at
AND NEW.scheduled_at >= now() THEN
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 = NEW.id
AND pg.is_public = true;
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. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has scheduled_at >= now(). The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default READ COMMITTED isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. 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 atomically unpublishes linked public cards while preserving published_at; it updates the session before the card. At READ COMMITTED, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same sessions then portfolio_games lock order: explicitly 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, strengthen the initial session fetch with FOR UPDATE OF s. After authorization, run this statement inside the existing transaction before DELETE FROM sessions:
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. 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:
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 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 deferred trigger 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: save the bot project resource, add .WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health"), 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.
- Step 6: Run the Task 1 tests to verify GREEN
Run:
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 future reschedule, bounded READ COMMITTED publish/delete in both commit orders, 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, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating.
- Step 7: Commit
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): harden portfolio publication concurrency"
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:
var forbidden = new[]
{
"Id", "External", "Telegram", "Discord", "Moderator",
"StorageKey", "PhysicalPath", "JoinLink", "Session"
};
Add validation tests:
[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
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:
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:
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:
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:
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:
public static string NormalizeTitle(string? value)
Rules: trim, require length 2..255.
Implement:
public static string? NormalizeDescription(string? value)
Rules: null for whitespace, otherwise trim, maximum 5000.
Implement:
public static string NormalizeReviewBody(string? value)
Rules: trim, require length 10..2000.
Implement:
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
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:
[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:
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
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:
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, PNG89 50 4E 47 0D 0A 1A 0A, WebPRIFFplusWEBP; - 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:
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:
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
After security headers and before authentication, add:
app.UsePortfolioCoverFiles();
In development settings add:
"PortfolioCovers": {
"StoragePath": "../../artifacts/portfolio-covers"
}
In compose.yaml, mount:
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
and:
- portfolio_covers:/app/portfolio-covers
Declare:
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:
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
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
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:
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:
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:
Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal);
Add a regression assertion by reading SessionService.cs:
Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal);
- Step 2: Run source-contract tests to verify RED
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:
GetPublicPortfolioGamesForMasterAsync(string masterSlug)
GetPublicPortfolioGamesForClubAsync(string clubSlug)
GetPublicPortfolioGameBySlugAsync(string slug)
Rules:
-
Filter
portfolio_games.is_public = true. -
Master query joins
portfolio_game_mastersand publicmaster_profilesby slug but does not requiregame_groups.public_schedule_enabled. -
Club query joins
game_groupsand requirespublic_schedule_enabled = trueplus 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_keyto a public URL withcoverStorage.GetPublicPath. -
Public DTOs never carry private UUIDs.
-
Step 4: Implement protected reads and writes
Implement:
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 settingis_public = trueandpublished_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, orHidden, stores moderator ID and timestamp, and scopes the review to the managed adventure. -
Step 5: Implement authenticated review methods
Implement:
GetReviewSubmissionStateAsync
SubmitPortfolioReviewAsync
Rules:
-
Resolve linked player identities using the same
player_linksdirection asSessionService.ResolveEffectivePlayerIdAsync. -
Eligible means the public adventure has at least one linked past session with a matching
session_participants.player_id,sp.is_gm = false, andsp.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 usesON CONFLICT ... DO NOTHINGto reject duplicates. -
Step 6: Register portfolio store
In Program.cs add:
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
- Step 7: Run tests and build to verify GREEN
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
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:
[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
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:
public sealed class AuthorizedPortfolioService(
IPortfolioStore portfolioStore,
ISessionStore sessionStore,
IPortfolioCoverStorage coverStorage,
IHttpContextAccessor httpContextAccessor)
Implement management methods:
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:
GetReviewSubmissionStateForCurrentUserAsync(string slug)
SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent)
Rules:
-
Every management method checks
ISessionStore.IsGroupManagerAsync. -
GetCompletedSessionsForCurrentUserAsyncreturnsIPortfolioStore.GetEligibleCompletedSessionsAsync(groupId, null)only after the same manager check. -
Resolve the owning group through
GetPortfolioGameGroupIdAsyncbefore loading private editor data or applying any ID-scoped mutation. -
UpdateDraftForCurrentUserAsyncappliesPortfolioValidationto 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:
builder.Services.AddScoped<AuthorizedPortfolioService>();
- Step 5: Run tests and build to verify GREEN
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
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:
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
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 callingCreateDraftForCurrentUserAsync(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:
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
IBrowserFilewithOpenReadStream(LocalPortfolioCoverStorage.MaxBytes)andReplaceCoverForCurrentUserAsync; -
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
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
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:
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:
[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
PortfolioCardGridbelow 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
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
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:
Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal);
- Step 2: Run version test to verify RED
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, andgmrelay-webimage tags -
.gitea/workflows/deploy.yml:VERSION: 3.6.0 -
src/GmRelay.Web/Components/Layout/NavMenu.razor:v3.6.0 -
README.md: current versionv3.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, andIPortfolioCoverStorage; -
persistent
portfolio_coversvolume 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
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
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
Expected: all tests pass.
- Step 2: Run the full build
dotnet build
Expected: build succeeds with zero warnings and zero errors.
- Step 3: Run formatting verification
dotnet format --verify-no-changes --verbosity diagnostic
Expected: exit code 0.
- Step 4: Check version synchronization
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:
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
/showcasefuture-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:
- Schema
- Contracts and validation
- Cover storage
- Portfolio persistence
- Authorized orchestration
- Protected UI
- Public UI
- Documentation and version
- 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.