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

68 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.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
  • Current fix cycle: fix(data): serialize portfolio future reschedules

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("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:

[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: 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:

<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 PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt()

[Fact]
public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock()

[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 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 use an advisory test gate plus pg_blocking_pids observation with bounded timeouts, complete without card 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 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 re-reads the final session row, skips intermediate future values that end in the past, and for a final future value locks all currently public cards linked to any final-future session in portfolio_games.id order before one guarded unpublish update. This separate global row-lock pass avoids opposing batch order without adding the validator advisory lock before card locks. 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: 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:

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, opposing-order batch reschedules without card deadlock, 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 with a non-proxied bot endpoint and matching gmrelaydb resource name.

  • 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): 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:

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, 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:

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_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:

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:

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:

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.

  • 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:

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 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:

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
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 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
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, 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
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 /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.